Astro voyage - 初步認識太空

Argentum11
海大 SwiftUI iOS / Flutter App 程式設計
17 min readJun 12, 2024
  • 程式碼
  • 操作的影片
  • 串接網路上的 API 抓取 JSON 資料後以 ListView 或 GridView 顯示,點選項目可到下一頁顯示 detail,至少使用到兩個 API

使用到的API 如下:

  1. Astronomy Picture of the Day (https://api.nasa.gov/planetary/apod)

下圖中的照片和標題是透過此 API 取得,每天取得的圖片皆不相同

2. Space Weather Database Of Notifications, Knowledge, Information-Coronal Mass Ejection (https://api.nasa.gov/DONKI/CME?startDate=yyyy-MM-dd&endDate=yyyy-MM-dd&api_key=DEMO_KEY)

日冕巨量噴發的簡單介紹及記錄

3. Space Weather Database Of Notifications, Knowledge, Information - Solar Flare (https://api.nasa.gov/DONKI/FLR?startDate=yyyy-MM-dd&endDate=yyyy-MM-dd&api_key=DEMO_KEY)

太陽閃焰的簡單介紹及記錄

4. NASA Image and Video Library (https://images-api.nasa.gov)

透過文字在 NASA 的資料庫搜尋資料,主要結果都是照片和影片,有極少數的 podcast

  • 將 JSON 轉換成自訂型別
class AstronomyPictureOfTheDay {
final String date;
final String explanation;
final String title;
final String imageUrl;

AstronomyPictureOfTheDay(
{required this.date,
required this.explanation,
required this.title,
required this.imageUrl});

factory AstronomyPictureOfTheDay.fromJson(Map<String, dynamic> json) {
return AstronomyPictureOfTheDay(
date: json['date'],
explanation: json['explanation'],
title: json['title'],
imageUrl: json['url'],
);
}
}
class CoronalMassEjection {
final String dateTime;
final String note;
final List<Spacecraft> spacecrafts;
final double speed;

CoronalMassEjection(
{required this.dateTime,
required this.note,
required this.spacecrafts,
required this.speed});

factory CoronalMassEjection.fromJson(Map<String, dynamic> json) {
var spacecraftsFromJson = json['instruments'] as List;
List<Spacecraft> spacecraftList =
spacecraftsFromJson.map((i) => Spacecraft.fromJson(i)).toList();
String iso8601FormatDatetime = json['startTime'];
// speed
var cmeAnalysesJson = json['cmeAnalyses'] as List;
List<CMEAnalyses> cmeAnalyses =
cmeAnalysesJson.map((i) => CMEAnalyses.fromJson(i)).toList();
double speed = 0;
for (int i = 0; i < cmeAnalyses.length; i++) {
CMEAnalyses cmeAnalysis = cmeAnalyses[i];
if (cmeAnalysis.accurate) {
speed = cmeAnalysis.speed;
break;
}
}
return CoronalMassEjection(
dateTime: formatDateTime(iso8601FormatDatetime),
note: removeVisibleTo(json['note']),
spacecrafts: spacecraftList,
speed: speed);
}
}
class SolarFlare {
SolarFlare(
{required this.dateTime,
required this.type,
required this.location,
required this.description});
String dateTime;
String type;
String location;
String description;

factory SolarFlare.fromJson(Map<String, dynamic> json) {
String iso8601FormatDatetime = json['peakTime'];

return SolarFlare(
dateTime: formatDateTime(iso8601FormatDatetime),
type: json['classType'][0],
location: json['sourceLocation'],
description: json['note']);
}
}
class SearchResultCollection {
SearchResultCollection(
{required this.totalHits,
required this.nextPageUrl,
required this.results});
int totalHits;
String nextPageUrl;
List<SearchResultItem> results = [];

factory SearchResultCollection.fromJson(Map<String, dynamic> json) {
json = json['collection'];
var links = json['links'] as List;
String nextPageUrl = '';
for (int i = 0; i < links.length; i++) {
var link = links[i];
if (link['rel'] == 'next') {
nextPageUrl = link['href'];
break;
}
}
var resultsFromJson = json['items'] as List;
List<SearchResultItem> resultList =
resultsFromJson.map((i) => SearchResultItem.fromJson(i)).toList();
//

return SearchResultCollection(
totalHits: json['metadata']['total_hits'],
nextPageUrl: nextPageUrl,
results: resultList);
}
}

class SearchResultItem {
SearchResultItem(
{required this.previewImageUrl,
required this.title,
required this.nasaId,
required this.description,
required this.media});
String? previewImageUrl;
String title;
String nasaId;
String description;
String media;

factory SearchResultItem.fromJson(Map<String, dynamic> json) {
// previewImageUrl
String? previewImageUrl;
try {
var linkList = json['links'] as List;
for (int i = 0; i < linkList.length; i++) {
var link = linkList[i];
if (link['render'] == 'image') {
previewImageUrl = link['href'];
}
}
} catch (e) {
// this item might be audio
}

var dataList = json['data'] as List;
var data = dataList[0];
String title = data['title'];
String nasaId = data['nasa_id'];
String description = data['description'];
String media = data['media_type'];

return SearchResultItem(
previewImageUrl: previewImageUrl,
title: title,
nasaId: nasaId,
description: description,
media: media);
}
}
  • 畫面正在抓資料時顯示資料下載中(使用CircularProgressIndicator)
class CircularProgressWithTitle extends StatelessWidget {
const CircularProgressWithTitle({super.key, required this.title});
final String title;

@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(
height: 20,
),
Text('$title...'),
],
);
}
}
  • 抓不到資料,比方網路有問題時,畫面上顯示錯誤資訊
FutureBuilder(
future: fetchCoronalMassEjection(),
builder: ((context, snapshot) {
if (snapshot.hasData) {
...
} else if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
...
}),
),
  • 使用 SearchBar 實現 search 功能

這個畫面需要呈現一個 SearchBar 和搜尋結果 (ListView),由於希望 SearchBar 能在下滑時和其他搜尋結果一起被捲上去,使用 SingleChildScrollView ,裡面包含 SearchBar 和 ListView 時會出現 error,這是因為 ListView 預設要填满整個 parent widget,所以改用 CustomScrollView,一個提供更多客制化排版的 widget。(不過這也讓下拉更新功能更難實現,目前尚未完成)

除此之外,SearchBar 上還有一個清空搜尋文字的按鈕,它會在編輯文字時出現

CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: SearchAnchor(
builder: (context, controller) {
return SearchBar(
leading: const Icon(Icons.search),
controller: controller,
focusNode: _searchFocusNode,
hintText: 'Search for data in Nasa library',
textInputAction: TextInputAction.search,
onSubmitted: (value) {
setState(() {
futureSearchResults = null;
_searchText = value;
futureSearchResults = fetchSearchResult();
inputFinished = true;
});
},
onTap: () {
setState(() {
inputFinished = false;
});
},
onChanged: (value) {
setState(() {
_searchText = value;
});
},
trailing: [
if (_searchText != '' && !inputFinished)
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
controller.text = '';
_searchFocusNode.requestFocus();
},
)
],
);
},
suggestionsBuilder: (context, controller) {
return [];
},
),
),
FutureBuilder(
future: futureSearchResults,
builder: ((context, snapshot) {
if (snapshot.hasData) {
var apiData = snapshot.data!;
SearchResultCollection searchResultCollection = apiData;
var items = searchResultCollection.results;

return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
SearchResultItem searchResultItem = items[index];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (index == 0)
SearchResultAmountBlock(
resultAmount:
searchResultCollection.totalHits),
SearchResultItemTile(
searchResultItem: searchResultItem),
],
);
},
childCount: items.length,
),
);
} else if (snapshot.hasError) {
return SliverToBoxAdapter(
child: Text('Error: ${snapshot.error}'));
}
return SliverToBoxAdapter(
child: CircularProgressWithTitle(
title: 'Searching for $_searchText'),
);
}),
),
],
),
  • 使用 Firebase 開發註冊登入功能
TextButton(
child: const Text('login'),
onPressed: () async {
final email = _emailController.text.trim();
final password = _passwordController.text.trim();
try {
await FirebaseAuth.instance.signInWithEmailAndPassword(
email: email,
password: password,
);

if (context.mounted) {
FocusScope.of(context).unfocus();
// When you use async/await or Futures, your code enters an asynchronous zone,
// meaning it might continue execution even after the widget tree that provided the BuildContext has been disposed of,
// that's why directly access context in this async function (onPressed function for login button)
// without any check might cause errors
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => const AstroPage(),
),
);
}
} on FirebaseAuthException catch (e) {
if (context.mounted) {
if (e.code == 'user-not-found') {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${e.toString()} No user found for the provided email.($email)'),
),
);
} else if (e.code == 'wrong-password') {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Wrong password'),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.code.toString()),
),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
),
);
} // Print any other errors
}
},
)

--

--