接下來我們試著將 Flutter 專案與後端伺服器聯絡
首先登入的部分,實作時發現 InheritedWidget
不太夠用,因此又加入了 ChangeNotifier
幫忙管理狀態。
class MeStateNotifier with ChangeNotifier {
Me? _me;
Me? get me => _me;
set me(Me? me) {
_me = me;
notifyListeners();
}
}
並將原本的 MeDataLayer
稍作修改
class MeDataLayer extends InheritedWidget {
const MeDataLayer({super.key, required this.meState, required super.child});
final MeStateNotifier meState;
static MeDataLayer of(BuildContext context) {
final res = context.dependOnInheritedWidgetOfExactType<MeDataLayer>();
assert(res != null, 'No UserData found in context');
return res!;
}
@override
bool updateShouldNotify(MeDataLayer oldWidget) {
return oldWidget.meState.me != meState.me;
}
}
接著我們處理 http
的部分。
登入時會將帳號與密碼交給後端進行確認,如果符合,就將 token
儲存在 SharedPreferences
中。登出時則向後端請求清除 token
,並且將裝置的快取也做刪除。
class Me {
// ...
static Future<Me> login(String user, String password) async {
var res = await http.post(
Uri(host: "localhost", port: 8081, path: "/api/v1/auth/login"),
headers: {"Content-Type": "application/json"},
body:
jsonEncode(<String, dynamic>{"user": user, "password": password}));
if (res.statusCode != 200) {
throw (res);
}
Map content = jsonDecode(res.body);
String token = content['token'];
res = await http.get(Uri(host: "localhost", port: 8081, path: "/api/v1/me"),
headers: {"Authorization": token});
if (res.statusCode != 200) {
throw (res);
}
var pref = await SharedPreferences.getInstance();
pref.setString("token", token);
content = jsonDecode(res.body);
return Me(
uid: content["uid"],
id: content["user"],
name: content["name"],
profile: content["profile"] == "" ? null : content["profile"],
publicKey: content["public_key"]);
}
static Future<void> logout() async {
var pref = await SharedPreferences.getInstance();
String? token = pref.getString("token");
if (token == null) return;
var res = await http.post(
Uri(host: "localhost", port: 8081, path: "/api/v1/auth/logout"),
headers: {"Authorization": token});
if (res.statusCode != 204) {
throw ('unexpected error');
}
await pref.remove("token");
}
}
而在一開始決定是否登入的畫面則依據 SharedPreferences
是否有值來決定
class _RoutingLoginOrMeState extends State<RoutingLoginOrMe> {
late Future<(bool, Me?)> _checkKeepLogin;
@override
void initState() {
super.initState();
_checkKeepLogin = check();
}
Future<(bool, Me?)> check() async {
var p = await SharedPreferences.getInstance();
var token = p.getString("token");
if (token == null) {
return (false, null);
}
var res = await http.get(
Uri(host: "localhost", port: 8081, path: "/api/v1/me"),
headers: {"Authorization": token});
if (res.statusCode != 200) {
throw (res);
}
var content = jsonDecode(res.body);
return (
true,
Me(
uid: content["uid"],
id: content["user"],
name: content["name"],
profile: content["profile"] == "" ? null : content["profile"],
publicKey: content["public_key"])
);
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _checkKeepLogin,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Scaffold(body: MyErrorWidget(snapshot.error.toString()));
} else if (snapshot.hasData) {
if (snapshot.data!.$1 == false) {
return const LoginPage();
}
MeDataLayer.of(context).meState.me = snapshot.data!.$2;
return const MePage();
}
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
));
});
}
}
當然,這會有個問題,如果 SharedPreferences
中的 token
有問題,就會一直卡在錯誤畫面,因此我們需要在錯誤發生時也將 SharedPreferences
清空。
if (snapshot.hasError) {
Me.logout();
MeDataLayer.of(context).meState.me = null;
return Scaffold(body: MyErrorWidget(snapshot.error.toString()));
}
接著我們可以處理朋友列表的部分,這裡的實作我們改在 SliverList
的 builder
實現下滑載入更多。當 index
等於目前 list
長度少一時,我們改呼叫 _load()
。由於最一開始是沒有長度的,因此我們在 initState()
即可先呼叫一次 _load()
並且我們預設一開始都是有資料可以載入的,直到無資料載入時我們再將 _loadedEnd
設為 false
class _HomePageState extends State<HomePage> {
final FriendListStateNotifier _friendListState = FriendListStateNotifier();
final _scrollCtrl = ScrollController();
bool _loadedEnd = false;
int from = 0;
@override
void initState() {
_load();
super.initState();
}
@override
void dispose() {
super.dispose();
_scrollCtrl.dispose();
}
Future<void> _load() async {
if (_loadedEnd) {
return;
}
var (loadedList, next) = await Friend.load(from);
from = next;
if (loadedList != null) {
_friendListState.addAll(loadedList);
} else {
if (mounted) {
setState(() {
_loadedEnd = true;
});
}
}
}
@override
Widget build(BuildContext context) {
Me me = MeDataLayer.of(context).meState.me!;
return ListenableBuilder(
listenable: _friendListState,
builder: (BuildContext context, Widget? child) {
return Scrollbar(
controller: _scrollCtrl,
child: CustomScrollView(
controller: _scrollCtrl,
slivers: <Widget>[
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 10),
ListTile(
leading: me.profile == null
? Image.asset("assets/default_profile.png")
: Image.network(me.profile!),
title: Text(
me.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
const Padding(
padding: EdgeInsets.symmetric(
horizontal: 16.0, vertical: 10),
child: Text("朋友",
style: TextStyle(
fontSize: 18,
)),
)
],
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index == _friendListState.list.length - 1) {
_load();
}
return FriendCard(
friend: _friendListState.list[index],
);
},
childCount: _friendListState.list.length,
),
),
SliverToBoxAdapter(
child: Center(
child: !_loadedEnd
? const CircularProgressIndicator()
: const SizedBox(),
)),
],
),
);
});
}
}
這裡也附上呼叫後端所使用的程式碼:
class Friend {
Friend(
{required this.profile,
required this.userName,
required this.userID,
required this.channelID});
String? profile;
String userName;
int userID;
int channelID;
static Future<(List<Friend>?, int)> load(int from) async {
var pref = await SharedPreferences.getInstance();
var token = pref.getString("token");
if (token == null) {
return (null, -1);
}
var res = await http.get(
Uri(host: "localhost", port: 8081, path: "/api/v1/friends/$from"),
headers: {
'Authorization': token,
});
if (res.statusCode != 200) {
throw (res);
}
var data = jsonDecode(res.body);
var list = data["list"] as List?;
if (list == null) {
return (null, -1);
}
return (
List.generate(list.length, (int i) {
return Friend(
profile: list[i]["profile"] != "" ? list[i]["profile"] : null,
userName: list[i]["name"],
userID: list[i]["uid"],
channelID: list[i]["channel_id"]);
}),
data["next"] as int
);
}
}
持續更新中...