#04 金泰亨的APP

洪瑋雪
海大 SwiftUI iOS / Flutter App 程式設計
35 min readJun 12, 2024

Flutter

功能需求

  • 串接網路上的 API 抓取 JSON 資料後以 ListView 或 GridView 顯示,點選項目可到下一頁顯示 detail,至少使用到兩個 API。
    Future<List<Track>> fetchTracks({required String query}) async {
final response = await http.get(Uri.parse(
'https://itunes.apple.com/search?term=$query&media=music&country=tw'));

if (response.statusCode == 200) {
List<dynamic> json = jsonDecode(response.body)['results'];
List<Track> tracks = json.map((data) => Track.fromJson(data)).toList();
return tracks;
} else {
throw Exception('Failed to load tracks');
}
}
final String youtubeApiKey = 'AIzaSyDBSSo9r8w2MkUIfpUeiuuEu57cyGV7HvQ';
Future<List<Video>> fetchVideos({required String query}) async {
final response = await http.get(Uri.parse(
'https://www.googleapis.com/youtube/v3/search?part=snippet&q=$query&type=video&key=$youtubeApiKey&maxResults=20'));

if (response.statusCode == 200) {
List<dynamic> json = jsonDecode(response.body)['items'];
List<Video> videos = json.map((data) => Video.fromJson(data)).toList();
return videos;
} else {
throw Exception('Failed to load videos');
}
}
final String accessKey = '8SaaCoSepfJCgS5Vxl4gRfAszh8LfiL4RZ8d0zirLYI';  
Future<void> fetchPhotos() async {
final response = await http.get(
Uri.parse(
'https://api.unsplash.com/search/photos?query=BTS Kim Taehyung&client_id=$accessKey'),
);

if (response.statusCode == 200) {
final data = json.decode(response.body);
final List<dynamic> results = data['results'];

setState(() {
photoUrls = results
.map<String>((photo) => photo['urls']['small'] as String)
.toList();
});
} else {
throw Exception('Failed to load photos');
}
}
child: ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
final track = snapshot.data![index];
return ListTile(
leading: Image.network(track.artworkUrl100), // 顯示圖片
title: Text(track.trackName),
subtitle: Text(track.artistName),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TrackDetailPage(track: track),
),
);
},
);
},
),
 child: ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
final video = snapshot.data![index];
return InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => VideoDetailPage(video: video),
),
);
},
child: Card(
elevation: 3,
margin: EdgeInsets.all(8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), // 設置Card圓角
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(10), // 設置圖片圓角
image: DecorationImage(
image: NetworkImage(video.thumbnailUrl),
fit: BoxFit.cover,
),
),
),
SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
video.title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 5),
Text(
video.channelTitle,
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
],
),
),
],
),
),
),
);
},
),
  • 將 JSON 轉換成自訂型別。
class Track {
final String trackName;
final String artistName;
final String artworkUrl100;
final String previewUrl;
final String albumName;
final String releaseDate;

Track({
required this.trackName,
required this.artistName,
required this.artworkUrl100,
required this.previewUrl,
required this.albumName,
required this.releaseDate,
});

factory Track.fromJson(Map<String, dynamic> json) {
return Track(
trackName: json['trackName'],
artistName: json['artistName'],
artworkUrl100: json['artworkUrl100'],
previewUrl: json['previewUrl'],
albumName: json['collectionName'] ?? '',
releaseDate: json['releaseDate'] ?? '',
);
}

Null get videoId => null;

get trackId => null;
}
class Video {
final String title;
final String channelTitle;
final String thumbnailUrl;
final String videoId;
final DateTime? publishedAt;
final String? description;

Video({
required this.title,
required this.channelTitle,
required this.thumbnailUrl,
required this.videoId,
this.publishedAt,
this.description,
});

factory Video.fromJson(Map<String, dynamic> json) {
return Video(
title: json['snippet']['title'],
channelTitle: json['snippet']['channelTitle'],
thumbnailUrl: json['snippet']['thumbnails']['default']['url'],
videoId: json['id']['videoId'],
);
}
}
  • 畫面正在抓資料時顯示資料下載中,比方使用 LinearProgressIndicator。
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
LinearProgressIndicator(),
SizedBox(height: 20),
Text('資料下載中...'),
],
),
);
  • 抓不到資料,比方網路有問題時,畫面上顯示錯誤資訊。
Text(
'Failed to load tracks: ${snapshot.error}',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 18, color: Colors.red),
),
  • 下拉更新功能,比方使用 RefreshIndicator。
return RefreshIndicator(
onRefresh: () async {
await refreshTracks();
},
  • 使用 SearchBar 實現 search 功能。
child: SearchBar(
onChanged: filterTracks,
hintText: '搜尋音樂',
),
child: SearchBar(
onChanged: filterVideos,
hintText: '搜尋影片',
),
  • 開發註冊登入功能。
import 'package:final_app/main.dart';

class AuthService {
final Map<String, String> users = {
'user1': 'password1',
'user2': 'password2',
};

Future<User?> login(
{required String username, required String password}) async {
if (users.containsKey(username) && users[username] == password) {
return User(username: username, password: password);
} else {
return null;
}
}

Future<User?> register(
{required String username, required String password, required String email}) async {
if (!users.containsKey(username)) {
users[username] = password;
return User(username: username, password: password);
} else {
return null;
}
}
}
import 'package:final_app/main.dart';
import 'package:flutter/material.dart';

import 'auth_service.dart';

class RegisterPage extends StatelessWidget {
final AuthService authService;
final TextEditingController usernameController = TextEditingController();
final TextEditingController passwordController = TextEditingController();

RegisterPage({required this.authService});

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
'Register Page',
style: TextStyle(
fontSize: 24, // 設置標題字體大小
fontWeight: FontWeight.bold, // 設置標題字體加粗
color: Colors.white, // 設置標題字體顏色
),
),
centerTitle: true, // 讓標題居中顯示
backgroundColor:
const Color.fromARGB(255, 97, 143, 188), // 设置AppBar背景颜色
),
body: Stack(
children: [
// 背景图片
Positioned.fill(
child: Image.network(
'https://renwu.rbaike.com/uploads/202210/1664789156Bw60cU27.jpg',
fit: BoxFit.cover,
),
),
Padding(
padding: EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
decoration: BoxDecoration(
color: Colors.white
.withOpacity(0.8), // Add opacity to make text readable
borderRadius: BorderRadius.circular(10),
),
child: TextField(
controller: usernameController,
decoration: InputDecoration(
hintText: 'Username',
contentPadding: EdgeInsets.all(10),
border: InputBorder.none,
),
),
),
SizedBox(height: 16),
Container(
decoration: BoxDecoration(
color: Colors.white
.withOpacity(0.8), // Add opacity to make text readable
borderRadius: BorderRadius.circular(10),
),
child: TextField(
controller: passwordController,
decoration: InputDecoration(
hintText: 'Password',
contentPadding: EdgeInsets.all(10),
border: InputBorder.none,
),
obscureText: true,
),
),
SizedBox(height: 16),
ElevatedButton(
onPressed: () async {
String username = usernameController.text;
String password = passwordController.text;
User? user = await authService.register(
username: username, password: password, email: '');
if (user != null) {
Navigator.pop(context); // 返回到登錄頁面
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
'Registration successful. You can now log in.'),
));
} else {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Registration failed. Please try again.'),
));
}
},
child: Text('Register'),
),
],
),
),
],
),
);
}
}

import 'package:final_app/api_service.dart';
import 'package:final_app/home_page.dart';
import 'package:final_app/main.dart';
import 'package:final_app/register_page.dart';
import 'package:flutter/material.dart';
import 'auth_service.dart';

class LoginPage extends StatelessWidget {
final AuthService authService;
final TextEditingController usernameController = TextEditingController();
final TextEditingController passwordController = TextEditingController();

LoginPage({required this.authService});

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
'Login Page',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
centerTitle: true,
backgroundColor:
const Color.fromARGB(255, 74, 114, 155), // 設置AppBar背景颜色
),
body: Stack(
children: [
// Background image
Positioned.fill(
child: Image.network(
'https://pic.pimg.tw/kidkid1121/1521364859-510615360_n.jpg', // Placeholder image path
fit: BoxFit.cover,
),
),
Padding(
padding: EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
decoration: BoxDecoration(
color: Colors.white
.withOpacity(0.8), // Add opacity to make text readable
borderRadius: BorderRadius.circular(10),
),
child: TextField(
controller: usernameController,
decoration: InputDecoration(
hintText: 'Username',
contentPadding: EdgeInsets.all(10),
border: InputBorder.none,
),
),
),
SizedBox(height: 16),
Container(
decoration: BoxDecoration(
color: Colors.white
.withOpacity(0.8), // Add opacity to make text readable
borderRadius: BorderRadius.circular(10),
),
child: TextField(
controller: passwordController,
decoration: InputDecoration(
hintText: 'Password',
contentPadding: EdgeInsets.all(10),
border: InputBorder.none,
),
obscureText: true,
),
),
SizedBox(height: 16),
ElevatedButton(
onPressed: () async {
String username = usernameController.text;
String password = passwordController.text;
User? user = await authService.login(
username: username, password: password);
if (user != null) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
HomePage(apiService: ApiService(), email: '', name: '', token: '',)),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
'Login failed. Please check your username and password.'),
));
}
},
child: Text('Login'),
),
SizedBox(height: 8),
TextButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
RegisterPage(authService: authService)),
);
},
child: Text('Create an account'),
),
],
),
),
],
),
);
}
}

  • 使用 showDialog 或 showSnackBar 顯示登入失敗。
else {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
'Login failed. Please check your username and password.'),
));
}
},
  • 使用到至少一個沒教過的功能技術,使用愈多分數愈高。可在文章裡特別說明使用哪些沒教的技術。
  • 連結到YouTube撥放影片
ElevatedButton(
onPressed: () {
launch(
'https://www.youtube.com/watch?v=${widget.video.videoId}');
},
style: ElevatedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 16),
backgroundColor: Color.fromARGB(255, 152, 184, 211),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
), // 設置按紐顏色
),
child: Text(
'Watch Video',
style: TextStyle(fontSize: 18, color: Colors.white),
),
),
  • 開頭動畫
import 'dart:ui';
import 'package:flutter/material.dart';
import 'auth_service.dart';
import 'login_page.dart';

class SplashScreen extends StatefulWidget {
final AuthService authService;
const SplashScreen({Key? key, required this.authService}) : super(key: key);

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

class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _opacityAnimation;
late Animation<double> _scaleAnimation;
late Animation<double> _blurAnimation;
late Animation<Offset> _slideAnimation;

@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: Duration(seconds: 5),
);

_opacityAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0, 0.5, curve: Curves.easeOut),
),
);

_scaleAnimation = TweenSequence<double>([
TweenSequenceItem(tween: Tween<double>(begin: 1.0, end: 1.2), weight: 50),
TweenSequenceItem(tween: Tween<double>(begin: 1.2, end: 1.0), weight: 50),
]).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0, 0.7, curve: Curves.easeInOut),
),
);

_blurAnimation = Tween<double>(begin: 10, end: 0).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0, 0.7, curve: Curves.easeOut),
),
);

_slideAnimation = Tween<Offset>(
begin: Offset(0, 1),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0.5, 0.8, curve: Curves.easeOut),
),
);

_controller.forward();

_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
Navigator.of(context).pushReplacement(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
LoginPage(authService: widget.authService),
transitionsBuilder:
(context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
),
);
}
});
}

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

@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Container(
child: Stack(
children: [
Positioned.fill(
child: Opacity(
opacity: _opacityAnimation.value,
child: Transform.scale(
scale: _scaleAnimation.value,
child: ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: _blurAnimation.value,
sigmaY: _blurAnimation.value,
),
child: Image.network(
'https://hips.hearstapps.com/hmg-prod/images/ue-64dca249cf419.jpg?crop=0.496xw:0.990xh;0.504xw,0.00977xh&resize=1200:*',
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
),
),
),
),
),
SlideTransition(
position: _slideAnimation,
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 40),
child: Text(
'Welcome to Tae\'s App',
style: TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
),
);
},
),
);
}
}

加分功能

  • 動畫。
class _TrackDetailPageState extends State<TrackDetailPage>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;

@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
);

_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeIn,
);

_controller.forward();
}

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

@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey[200], // 設置背景顏色
appBar: AppBar(
title: Text(widget.track.trackName),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: FadeTransition(
opacity: _animation,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
elevation: 8.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius:
BorderRadius.vertical(top: Radius.circular(16.0)),
child: Image.network(
widget.track.artworkUrl100,
width: double.infinity,
height: 200,
fit: BoxFit.cover,
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.track.trackName,
style: TextStyle(
fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text(
widget.track.artistName,
style: TextStyle(
fontSize: 18, color: Colors.grey[600]),
),
SizedBox(height: 8),
Text(
'Album: ${widget.track.albumName}',
style: TextStyle(fontSize: 16),
),
SizedBox(height: 8),
Text(
'Release Date: ${widget.track.releaseDate}',
style: TextStyle(fontSize: 16),
),
SizedBox(height: 16),
],
),
),
],
),
),
],
),
),
),
);
}
}
class _VideoDetailPageState extends State<VideoDetailPage>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;

@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
);

_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeIn,
);

_controller.forward();
}

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

@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey[200], // 設置背景顏色
appBar: AppBar(
title: Text(widget.video.title),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: FadeTransition(
opacity: _animation,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
elevation: 8.0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Image.network(
widget.video.thumbnailUrl,
fit: BoxFit.cover,
),
),
),
SizedBox(height: 16),
Text(
widget.video.title,
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text(
widget.video.channelTitle,
style: TextStyle(fontSize: 18),
),
SizedBox(height: 16),
ElevatedButton(
onPressed: () {
launch(
'https://www.youtube.com/watch?v=${widget.video.videoId}');
},
style: ElevatedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 16),
backgroundColor: Color.fromARGB(255, 152, 184, 211),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
), // 設置按紐顏色
),
child: Text(
'Watch Video',
style: TextStyle(fontSize: 18, color: Colors.white),
),
),
],
),
),
),
);
}
}

--

--