【Flutter 跨平台 App 程式設計入門】#04 期末專題

Fang
海大 SwiftUI iOS / Flutter App 程式設計
45 min readJun 11, 2024

本次APP的主題是與疾病相關~

GitHub連結:

App使用影片:

App頁面預覽:

由左至右依序為 App初始畫面、登入頁面、註冊畫面
Daily News頁面
Search頁面 及 點入的Detail Page
Chat Room畫面 與 Profile頁面

使用到的功能:

串接網路上的 API 抓取 JSON 資料⬇︎

Future<News> fetchNews(date) async {
final response =
await http.get(Uri.parse('https://www.hpa.gov.tw/wf/newsapi.ashx?startdate=$date&enddate=$date'));

if (response.statusCode == 200) {
// If the server did return a 200 OK response,
// then parse the JSON.
// debugPrint('${response.stream.bytesToString()}');
final jsonresponse = json.decode(response.body);
return News.fromJson(jsonresponse[0] as Map<String, dynamic>);
} else {
// If the server did not return a 200 OK response,
// then throw an exception.
throw Exception('Failed to load news');
}
}

串接網路上的 API 抓取 XML 資料⬇︎

Future<List<DiseaseTiles>> fetchDiseaseTiles(String searchTerm) async {
var url = Uri.parse(
'https://wsearch.nlm.nih.gov/ws/query?db=healthTopics&term=$searchTerm');
var response = await http.get(url);

if (response.statusCode == 200) {
var document = XmlDocument.parse(response.body);
var tiles = <DiseaseTiles>[];

// 解析XML並創建DiseaseTiles列表
var results = document.findAllElements('document');
for (var result in results) {
var title = result
.findElements('content')
.firstWhere((element) => element.getAttribute('name') == 'title');
var fullsummary = result
.findElements('content')
.firstWhere(
(element) => element.getAttribute('name') == 'FullSummary');
var snippet = result
.findElements('content')
.firstWhere((element) => element.getAttribute('name') == 'snippet');

var tile = DiseaseTiles(
title: title, fullsummary: fullsummary, snippet: snippet);
tiles.add(tile);
}

return tiles;
} else {
throw Exception('Failed to load tracks');
}
}

串接gemini API 抓取資料⬇︎

import 'package:google_generative_ai/google_generative_ai.dart';

Future<String?> generateResponse(String userInput) async {
const apiKey = ***;

final model = GenerativeModel(
model: 'gemini-1.5-flash-latest',
apiKey: apiKey,
);

final content = [Content.text(userInput)];
final response = await model.generateContent(content);
return response.text;
}

抓取 XML 資料後以 List 顯示,點選 Card 可到下一頁顯示 detail⬇︎

ListView.builder(
itemCount: diseases.length,
itemBuilder: (context, index) {
var disease = diseases[index];
return Card.outlined(
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DiseaseDetail(
title: disease.title,
fullsummary: disease.fullsummary,
)));
},
child: ListTile(
title:
// Html(data: disease.title),
Text(
parseHtmlString(disease.title),
style: const TextStyle(
fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Html(data: disease.snippet),
// Text(
// maxLines: 4,
// ),
],
),
),
),
);
},
),

將 JSON 轉換成自訂型別⬇︎

class News {
final String title;
final String body;
・・・

const News({
・・・
});

factory News.fromJson(Map<String, dynamic> json) {
return switch (json) {
{
・・・
} =>
News(
・・・
),
_ => throw const FormatException('Failed to load News.'),
};
}
}

將 XML 轉換成自訂型別⬇︎

class DiseaseTiles {
final String title;
・・・

DiseaseTiles({
・・・
});
}

畫面正在抓資料時顯示資料下載中⬇︎

if (searchTerm.isEmpty) {
return const SizedBox();
} else {
return const CircularProgressIndicator();
}

抓不到資料,畫面上顯示錯誤資訊⬇︎

if (snapshot.hasError) {
return Text('hasError ${snapshot.error}');
}

下拉更新功能⬇︎

Future<void> refresh() async {
setState(() {
futureDiseaseTiles = fetchDiseaseTiles(searchTerm);
});
await futureDiseaseTiles;
}

RefreshIndicator(
onRefresh: refresh,
child: ListView.builder(
itemCount: diseases.length,
itemBuilder: (context, index) {
・・・
},
),
);

使用 SearchBar 實現 search 功能⬇︎

SearchAnchor(
builder: (context, controller) {
return SearchBar(
leading: const Icon(Icons.search),
controller: controller,
hintText: 'Search disease',
textInputAction: TextInputAction.search,
onSubmitted: (value) {
setState(() {
・・・
});
},
);
},
suggestionsBuilder:
(BuildContext context, SearchController controller) {
・・・
},
),

使用 FavQs API 開發註冊登入功能⬇︎

Future<void> _postData() async {
try {
final response = await http.post(
Uri.parse(apiUrl),
headers: <String, String>{
'Authorization': ・・・,
'Content-Type': 'application/json',
},
body: jsonEncode({
"user": {
'login': loginController.text,
'password': passwordController.text,
// Add any other data you want to send in the body
}
}),
);

final responseData = jsonDecode(response.body);
debugPrint('Response data: ${response.body}');

・・・

if (response.statusCode == 200) {
// Successful POST request, handle the response here
result =
'User-Token: ${responseData['User-Token']}\nLogin: ${responseData['login']}\nEmail: ${responseData['email']}';
login = responseData['login'];
debugPrint(login);
if (login == loginController.text) {
・・・
} else {
snack(warning, context);
debugPrint("here");
}
} else {
// If the server returns an error response, throw an exception
throw Exception(
"Request to $apiUrl failed with status ${response.statusCode}: ${response.body}");
}
} catch (e) {
setState(() {
if (warning == '') {
result = 'Error:\n$e';
warning = result;
}
snack(warning, context);
warning = '';
});
}

・・・
}

使用 showDialog 顯示登入成功⬇︎

showDialog(
context: context,
builder: (BuildContext context) {
return _dialogBuilder();
},
);

使用 showSnackBar 顯示登入失敗⬇︎

void snack(String warning, BuildContext context) {
SnackBar snackBar = SnackBar(
content: Text(warning),
behavior: SnackBarBehavior.floating,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}

使用到 HTML 渲染⬇︎

Html(data: ・・・)

使用到 HTML 轉 String⬇︎

String parseHtmlString(String htmlString) {
final document = html_parser.parse(htmlString);
final String parsedString =
html_dom.DocumentFragment.html(document.body!.text).text!;
return parsedString;
}

使用到 GiffyDialog ⬇︎

GiffyDialog.image(
Image.network(
"https://raw.githubusercontent.com/Shashank02051997/FancyGifDialog-Android/master/GIF's/gif14.gif",
height: 200,
fit: BoxFit.cover,
),
title: const Text(
'No results found.',
textAlign: TextAlign.center,
),
content: const Text(
'Please re-enter the query content.',
textAlign: TextAlign.center,
),
),

使用到 flutter_spinkit ⬇︎

SpinKitFadingCircle(
color: Colors.red,
size: 50.0,
),

SpinKitCircle(
color: Colors.blue,
size: 50.0,
);

使用到 onPanUpdate ⬇︎

Positioned(
left: offset.dx,
top: offset.dy,
child: GestureDetector(
onPanUpdate: (d) =>
setState(() => offset += Offset(d.delta.dx, d.delta.dy)),
child: FloatingActionButton(
onPressed: _presentDatePicker,
backgroundColor: Colors.orange,
child: const Icon(Icons.date_range_rounded),
),
),
),

使用到 HollowText ⬇︎

HollowText(
text: snapshot.data!.title
.replaceAll(RegExp(r"\s+"), "\n"),
size: 23,
hollowColor: Colors.white,
strokeColor: Colors.black,
strokeWidth: 3,
),

使用到 url_launcher ⬇︎

Future<void> _launchUrl(String url) async {
Uri _url = Uri.parse(url);
if (!await launchUrl(_url)) {
throw Exception('Could not launch $_url');
}
}

_launchUrl(snapshot.data!.link);

使用到 DatePicker ⬇︎

void _presentDatePicker() async {
final pickedDate = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(1900),
lastDate: DateTime.now(),
);
setState(() {
if (pickedDate != null) {
selectedDate = pickedDate;
debugPrint(dateFormatter.format(selectedDate));
futureNews = fetchNews(dateFormatter.format(selectedDate));
// _age = DateTime.now().year - pickedDate.year;
}
});
}

使用到 RegExp ⬇︎

.replaceAll(RegExp(r"\s+"), "\n"),
RegExp limitName = RegExp(r'^(?=.*[A-Za-z])[A-Za-z0-9_]+$');
RegExp limitEmail = RegExp("^([a-z0-9A-Z]+[-|\\.]?)+[a-z0-9A-Z]@([a-z0-9A-Z]+(-[a-z0-9A-Z]+)?\\.)+[a-zA-Z]{2,}\$");

使用到 Flexible ⬇︎

Flexible(
child: Html(
data: 'publication Date:\n${snapshot.data!.pubdate}'),
),

使用到 floatsutton ⬇︎

import 'dart:math';
import 'package:flutter/material.dart';
export 'package:draggable_widget/model/anchor_docker.dart';

enum AnchoringPosition {
topLeft,
topRight,
bottomLeft,
bottomRight,
center,
}

class DraggableWidget extends StatefulWidget {
/// The widget that will be displayed as dragging widget
final Widget child;

/// The horizontal padding around the widget
final double horizontalSpace;

/// The vertical padding around the widget
final double verticalSpace;

/// Intial location of the widget, default to [AnchoringPosition.bottomRight]
final AnchoringPosition initialPosition;

/// Intially should the widget be visible or not, default to [true]
final bool intialVisibility;

/// The top bottom pargin to create the bottom boundary for the widget, for example if you have a [BottomNavigationBar],
/// then you may need to set the bottom boundary so that the draggable button can't get on top of the [BottomNavigationBar]
final double bottomMargin;

/// The top bottom pargin to create the top boundary for the widget, for example if you have a [AppBar],
/// then you may need to set the bottom boundary so that the draggable button can't get on top of the [AppBar]
final double topMargin;

/// Status bar's height, default to 24
final double statusBarHeight;

/// Shadow's border radius for the draggable widget, default to 10
final double shadowBorderRadius;

/// A drag controller to show/hide or move the widget around the screen
final DragController? dragController;

final BoxShadow normalShadow;

final BoxShadow draggingShadow;

/// How much should the [DraggableWidget] be scaled when it is being dragged, default to 1.1
final double dragAnimationScale;

/// Touch Delay Duration. Default value is zero. When set, drag operations will trigger after the duration.
final Duration touchDelay;

const DraggableWidget({
super.key,
required this.child,
this.horizontalSpace = 0,
this.verticalSpace = 0,
this.initialPosition = AnchoringPosition.bottomRight,
this.intialVisibility = true,
this.bottomMargin = 0,
this.topMargin = 0,
this.statusBarHeight = 24,
this.shadowBorderRadius = 10,
this.dragController,
this.dragAnimationScale = 1.1,
this.touchDelay = Duration.zero,
this.normalShadow = const BoxShadow(
color: Colors.transparent,
offset: Offset(0, 4),
blurRadius: 2,
),
this.draggingShadow = const BoxShadow(
color: Colors.black12,
offset: Offset(0, 1),
blurRadius: 10,
),
}) : assert(statusBarHeight >= 0),
assert(horizontalSpace >= 0),
assert(verticalSpace >= 0),
assert(bottomMargin >= 0);
@override
_DraggableWidgetState createState() => _DraggableWidgetState();
}

class _DraggableWidgetState extends State<DraggableWidget>
with SingleTickerProviderStateMixin {
double top = 0, left = 0;
double boundary = 0;
late AnimationController animationController;
late Animation animation;
double hardLeft = 0, hardTop = 0;
bool offstage = true;

AnchoringPosition? currentDocker;

double widgetHeight = 18;
double widgetWidth = 50;

final key = GlobalKey();

bool dragging = false;

late AnchoringPosition currentlyDocked;

bool? visible;

bool get currentVisibilty => visible ?? widget.intialVisibility;

bool isStillTouching = false;

@override
void initState() {
currentlyDocked = widget.initialPosition;
hardTop = widget.topMargin;
animationController = AnimationController(
value: 1,
vsync: this,
duration: const Duration(milliseconds: 150),
)
..addListener(() {
if (currentDocker != null) {
animateSideWidget(currentDocker!);
}
})
..addStatusListener(
(status) {
if (status == AnimationStatus.completed) {
hardLeft = left;
hardTop = top;
}
},
);

animation = Tween<double>(
begin: 0,
end: 1,
).animate(CurvedAnimation(
parent: animationController,
curve: Curves.easeInOut,
));

widget.dragController?._addState(this);

WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
final widgetSize = getWidgetSize(key);
if (widgetSize != null) {
setState(() {
widgetHeight = widgetSize.height;
widgetWidth = widgetSize.width;
});
}

await Future.delayed(const Duration(
milliseconds: 100,
));
setState(() {
offstage = false;
boundary = MediaQuery.of(context).size.height - widget.bottomMargin;
if (widget.initialPosition == AnchoringPosition.bottomRight) {
top = boundary - widgetHeight + widget.statusBarHeight;
left = MediaQuery.of(context).size.width - widgetWidth;
hardLeft = left;
pointerUpX = left;
} else if (widget.initialPosition == AnchoringPosition.bottomLeft) {
top = boundary - widgetHeight + widget.statusBarHeight;
left = 0;
} else if (widget.initialPosition == AnchoringPosition.topRight) {
top = widget.topMargin - widget.topMargin;
left = MediaQuery.of(context).size.width - widgetWidth;
hardLeft = left;
pointerUpX = left;
} else {
top = widget.topMargin;
left = 0;
}
});
});
super.initState();
}

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

@override
void didUpdateWidget(DraggableWidget oldWidget) {
if (offstage == false) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
final widgetSize = getWidgetSize(key);
if (widgetSize != null) {
setState(() {
widgetHeight = widgetSize.height;
widgetWidth = widgetSize.width;
});
}
setState(() {
boundary = MediaQuery.of(context).size.height - widget.bottomMargin;
animateSideWidget(currentlyDocked);
});
});
}
super.didUpdateWidget(oldWidget);
}

var pointerUpX = .0;

@override
Widget build(BuildContext context) {
return Positioned(
top: top,
left: left,
child: AnimatedSwitcher(
duration: const Duration(
milliseconds: 150,
),
transitionBuilder: (child, animation) {
return ScaleTransition(
scale: animation,
child: child,
);
},
child: !currentVisibilty
? Container()
: Listener(
onPointerUp: (v) {
if (!isStillTouching) {
return;
}
isStillTouching = false;

final p = v.position;
pointerUpX = p.dx;
currentDocker = determineDocker(p.dx, p.dy);
setState(() {
dragging = false;
});
if (animationController.isAnimating) {
animationController.stop();
}
animationController.reset();
animationController.forward();
},
onPointerDown: (v) async {
isStillTouching = false;
await Future.delayed(widget.touchDelay);
isStillTouching = true;
},
onPointerMove: (v) async {
if (!isStillTouching) {
return;
}
if (animationController.isAnimating) {
animationController.stop();
animationController.reset();
}

setState(() {
dragging = true;
if (v.position.dy < boundary &&
v.position.dy > widget.topMargin) {
top = max(v.position.dy - (widgetHeight) / 2, 0);
}

left = max(v.position.dx - (widgetWidth) / 2, 0);

hardLeft = left;
hardTop = top;
});
},
child: Offstage(
offstage: offstage,
child: Container(
key: key,
padding: EdgeInsets.symmetric(
horizontal: widget.horizontalSpace,
vertical: widget.verticalSpace,
),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(widget.shadowBorderRadius),
boxShadow: [
dragging
? widget.draggingShadow
: widget.normalShadow
// BoxShadow(
// color: Colors.black38,
// offset: dragging ? Offset(0, 10) : Offset(0, 4),
// blurRadius: dragging ? 10 : 2,
// )
],
),
child: Transform.scale(
scale: dragging ? widget.dragAnimationScale : 1,
child: widget.child)),
),
),
),
),
);
}

AnchoringPosition determineDocker(double x, double y) {
final double totalHeight = boundary;
final double totalWidth = MediaQuery.of(context).size.width;

if (x <= totalWidth / 2 && y <= totalHeight / 2) {
return AnchoringPosition.topLeft;
} else if (x < totalWidth / 2 && y > totalHeight / 2) {
return AnchoringPosition.bottomLeft;
} else if (x > totalWidth / 2 && y < totalHeight / 2) {
return AnchoringPosition.topRight;
} else {
return AnchoringPosition.bottomRight;
}
}

void animateSideWidget(AnchoringPosition docker) {
final double totalWidth = MediaQuery.of(context).size.width;
double remaingDistanceX = (totalWidth - widgetWidth - hardLeft);

debugPrint("animateWidget - ${totalWidth / 2} -- $pointerUpX");

if ((totalWidth / 2) <= pointerUpX) {
setState(() {
left = hardLeft + (animation.value) * remaingDistanceX;
});
} else {
setState(() {
left = (1 - animation.value) * hardLeft;
});
}
}

void animateWidget(AnchoringPosition docker) {
final double totalHeight = boundary;
final double totalWidth = MediaQuery.of(context).size.width;

switch (docker) {
case AnchoringPosition.topLeft:
setState(() {
left = (1 - animation.value) * hardLeft;
if (animation.value == 0) {
top = hardTop;
} else {
top = ((1 - animation.value) * hardTop +
(widget.topMargin * (animation.value)));
}

currentlyDocked = AnchoringPosition.topLeft;
});
break;
case AnchoringPosition.topRight:
double remaingDistanceX = (totalWidth - widgetWidth - hardLeft);
setState(() {
left = hardLeft + (animation.value) * remaingDistanceX;
if (animation.value == 0) {
top = hardTop;
} else {
top = ((1 - animation.value) * hardTop +
(widget.topMargin * (animation.value)));
}
currentlyDocked = AnchoringPosition.topRight;
});
break;
case AnchoringPosition.bottomLeft:
double remaingDistanceY = (totalHeight - widgetHeight - hardTop);
setState(() {
left = (1 - animation.value) * hardLeft;
top = hardTop +
(animation.value) * remaingDistanceY +
(widget.statusBarHeight * animation.value);
currentlyDocked = AnchoringPosition.bottomLeft;
});
break;
case AnchoringPosition.bottomRight:
double remaingDistanceX = (totalWidth - widgetWidth - hardLeft);
double remaingDistanceY = (totalHeight - widgetHeight - hardTop);
setState(() {
left = hardLeft + (animation.value) * remaingDistanceX;
top = hardTop +
(animation.value) * remaingDistanceY +
(widget.statusBarHeight * animation.value);
currentlyDocked = AnchoringPosition.bottomRight;
});
break;
case AnchoringPosition.center:
double remaingDistanceX =
(totalWidth / 2 - (widgetWidth / 2)) - hardLeft;
double remaingDistanceY =
(totalHeight / 2 - (widgetHeight / 2)) - hardTop;
// double remaingDistanceX = (totalWidth - widgetWidth - hardLeft) / 2.0;
// double remaingDistanceY = (totalHeight - widgetHeight - hardTop) / 2.0;
setState(() {
left = (animation.value) * remaingDistanceX + hardLeft;
top = (animation.value) * remaingDistanceY + hardTop;
currentlyDocked = AnchoringPosition.center;
});
break;
default:
}
}

Size? getWidgetSize(GlobalKey key) {
final keyContext = key.currentContext;
if (keyContext != null) {
final box = keyContext.findRenderObject() as RenderBox;
return box.size;
} else {
return null;
}
}

void _showWidget() {
setState(() {
visible = true;
});
}

void _hideWidget() {
setState(() {
visible = false;
});
}

void _animateTo(AnchoringPosition anchoringPosition) {
if (animationController.isAnimating) {
animationController.stop();
}
animationController.reset();
currentDocker = anchoringPosition;
animationController.forward();
}

Offset _getCurrentPosition() {
return Offset(left, top);
}
}

class DragController {
_DraggableWidgetState? _widgetState;
void _addState(_DraggableWidgetState widgetState) {
_widgetState = widgetState;
}

/// Jump to any [AnchoringPosition] programatically
void jumpTo(AnchoringPosition anchoringPosition) {
_widgetState?._animateTo(anchoringPosition);
}

/// Get the current screen [Offset] of the widget
Offset? getCurrentPosition() {
return _widgetState?._getCurrentPosition();
}

/// Makes the widget visible
void showWidget() {
_widgetState?._showWidget();
}

/// Hide the widget
void hideWidget() {
_widgetState?._hideWidget();
}
}

使用到 Slider & round⬇︎

Slider(
value: (_currentSliderPrimaryValue != 0)
? _currentSliderPrimaryValue
: age.toDouble(),
label: _currentSliderPrimaryValue.round().toString(),
max: 150,
divisions: 150,
onChanged: (double value) {
setState(() {
_currentSliderPrimaryValue = value;
});
},
),
Text(
_currentSliderPrimaryValue.round().toString(),
style: const TextStyle(fontSize: 20),
),

使用到 ToggleButtons ⬇︎

ToggleButtons(
direction: Axis.horizontal,
onPressed: (int index) {
setState(() {
// The button that is tapped is set to true, and the others to false.
for (int i = 0; i < _selectedsexual.length; i++) {
_selectedsexual[i] = i == index;
}
});
},
borderRadius: const BorderRadius.all(Radius.circular(8)),
selectedBorderColor: Colors.red[700],
selectedColor: Colors.white,
fillColor: Colors.red[200],
color: Colors.red[400],
constraints: const BoxConstraints(
minHeight: 40.0,
minWidth: 70.0,
),
isSelected: _selectedsexual,
children: sexual,
),

使用到 dash_chat_2 ⬇︎

DashChat(
typingUsers: typing,
currentUser: user,
onSend: _onSend,
messages: messages,
inputOptions: InputOptions(
alwaysShowSend: true, sendButtonBuilder: _sendButtons),
messageOptions: MessageOptions(
showOtherUsersAvatar: true,
currentUserContainerColor: const Color(0xff607DEF),
showTime: true,
avatarBuilder: (bot, onPressAvatar, onLongPressAvatar) {
return DefaultAvatar(
user: bot,
fallbackImage:
const AssetImage("assets/gemini_logo.png"));
},
),
scrollToBottomOptions: ScrollToBottomOptions(
scrollToBottomBuilder: _scrollToBottomBuilder,
),
),

使用到 HideKeyboard ⬇︎

GestureDetector(
child: child,
onTap: () {
FocusScopeNode currentFocus = FocusScope.of(context);
if (!currentFocus.hasPrimaryFocus &&
currentFocus.focusedChild != null) {
/// 取消焦点,相当于关闭键盘
FocusManager.instance.primaryFocus!.unfocus();
}
},
);

使用到 IconButton ⬇︎

IconButton(
icon: Icon(
!_isObscure ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_isObscure = !_isObscure;
});
},
),
控制密碼能否被看見

使用到 FilteringTextInputFormatter ⬇︎

TextFormField(
obscureText: _isObscure,
controller: passwordController,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.lock),
suffixIcon: ・・・
labelText: "Password *",
hintText: "Only allow num 0-9",
),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp("[0-9.]"))
],
),

使用到 BackdropFilter ⬇︎

BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0),
child: Container(
width: 300.0,
height: (widget.title == 'SIGN UP') ? 530 : 430,
decoration: BoxDecoration(
color: Colors.grey.shade200.withOpacity(0.5)),
child: Center(
child: (widget.title == 'SIGN UP')
? SignUp(title: widget.title)
: Login(title: widget.title),
),
),
),

包含其它欄位的註冊畫面⬇︎

動畫⬇︎

_animationController = AnimationController(
duration: const Duration(seconds: 1),
vsync: this, // 使用 vsync
)..repeat(reverse: true);

_animation = Tween<Offset>(
begin: const Offset(0, -0.1),
end: const Offset(0, 0.1),
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));

SlideTransition(
position: _animation,
child: const Text(
'Unable to fetch data.\nPlease try again later.',
style: TextStyle(
fontSize: 18,
color: Color.fromARGB(255, 59, 16, 16),
),
textAlign: TextAlign.center,
),
),

資料儲存⬇︎

final prefs = await SharedPreferences.getInstance();
final userData = json.encode({
'token': token,
'userId': login,
'email': email,
});

debugPrint('sign up');
debugPrint('token: $token');
debugPrint('login: $login');
debugPrint('email: $email');

prefs.setString('userData', userData);

自動登入⬇︎

Future<bool> tryAutoLogin() async {
final prefs = await SharedPreferences.getInstance();
if (!prefs.containsKey('userData')) {
return false; // 没有'userData'这一项,返回 false,自动登录失败
}
final extractedUserData = json.decode(prefs.getString('userData')!);
token = extractedUserData['token'].toString();
login = extractedUserData['userId'].toString();
email = extractedUserData['email'].toString();

debugPrint('log in');
debugPrint('token: $token');
debugPrint('login: $login');
debugPrint('email: $email');

Navigator.of(context).pop();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => Home(
email: email,
name: login,
token: token,
),
),
);

刪除登入⬇︎

Future<void> logout() async {
final prefs = await SharedPreferences.getInstance();
prefs.clear();
}

心得:

雖然這次Flutter的期末專案與IOS一樣是要串接API,但我覺得Flutter的難度似乎更高。這次除了原本的JSON檔外,我還嘗試用了XML檔,也接了上學期Peter推薦的Google Gemini API。在這次的App中我使用了很多很多的Package,發現到了Flutter的Package方便之處!像是Chat Room的UI部分便是由Package所製作出來的!感謝所有所有開發Package的開發者!

在整個學期過完後,深刻的體悟到了上學期SwiftUI的美好,以及製作遊戲真的好難啊!不過在學習完Flutter後,便能開發跨平台的App啦!

謝謝這學期Peter的教導,也祝大家都能有個充實的暑假~

--

--