第 20 天了,是時候來刻 UI 了,首先,我們的程式一進入後,要先向 Shared preferences 檢查是否已經登入,如果是已登入者,直接渲染主畫面 MePage
,若無則渲染登入頁面 LoginPage
。在登入畫面中,以一個連結連到註冊頁面 SignUpPage
。另外,為了將登入狀態保存好,實作一個 Me
class 保存用戶資料以及綁定相關邏輯,狀態的部分則交由 MeDataLayer
進行保存。
參考程式碼:https://github.com/ksw2000/ironman-2024/tree/a2f53698e424af23ac9d6aba699ffd4686dd33b4/whisper
因為是 final project,所以程式碼會分次不同 commit 到 Github 上,參考程式碼僅是某次的版本
加入一個 MeDataLayer
來儲存使用者「Me」的相關狀態。為了異步處理判斷初始化面應顯示「登入 LoginPage
」亦或是「已登入後的聊天畫面 MePage
」我們再插入一層 RoutingLoginOrMe
class _MyAPPState extends State<MyApp> {
Me? me;
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MeDataLayer(
user: me,
setUser: (user) {
setState(() {
me = user;
});
},
child: MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.pink),
useMaterial3: true,
),
home: const RoutingLoginOrMe(),
));
}
}
我們可以使用一個 FutureBuilder
來檢查保持登入與否,這裡的部分因為尚未實作,所以我們暫時以 delay 代替。
class _RoutingLoginOrMeState extends State<RoutingLoginOrMe> {
final Future<(bool, Me?)> _checkKeepLogin =
Future.delayed(const Duration(seconds: 3), () {
return (false, null);
});
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _checkKeepLogin,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('登入 Whisper'),
),
body: Center(
child: Column(
children: [
const Icon(
Icons.error_outline,
color: Colors.redAccent,
),
const SizedBox(
height: 20,
),
Text("${snapshot.error}")
],
)));
} else if (snapshot.hasData) {
if (snapshot.data!.$1 == false) {
// 渲染登入畫面
return const LoginPage();
}
return const MePage();
}
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('登入 Whisper'),
),
body: const Center(
child: CircularProgressIndicator(),
));
});
}
}
在 LoginPage
中除了渲染帳號密碼等元件外,還需要處理登入的動作!我們把登入者「Me」的資訊以「MeDataLayer」這個 InheritedWidget
做管理。另外,當我們成功能入後我們需要將 WidgetTree 的節點由 LoginPage
轉向 MePage
,此時使用的是 Navigator.pushReplacement
,此舉能避免使用者按下返回鍵後返回到登入頁面。
class _LoginPageState extends State<LoginPage> {
final idCtrl = TextEditingController();
final pwdCtrl = TextEditingController();
bool _isLogging = false;
@override
void dispose() {
idCtrl.dispose();
pwdCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('登入 Whisper'),
),
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
TextField(
controller: idCtrl,
decoration: const InputDecoration(hintText: '帳號'),
),
const SizedBox(
height: 10,
),
TextField(
controller: pwdCtrl,
obscureText: true,
decoration: const InputDecoration(hintText: '密碼'),
),
const SizedBox(
height: 20,
),
_isLogging
? const CircularProgressIndicator()
: ElevatedButton(
onPressed: () {
setState(() {
_isLogging = true;
});
Me.login(idCtrl.text, pwdCtrl.text).then((me) {
if (context.mounted) {
MeDataLayer.of(context).setUser(me);
Navigator.pushReplacement(context,
MaterialPageRoute(builder: (context) {
return const MePage();
}));
}
}).catchError((err) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("登入失敗:$err")));
}
}).whenComplete(() {
setState(() {
_isLogging = false;
});
});
},
child: const Text('登入')),
const SizedBox(
height: 20,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("沒有帳戶嗎?"),
TextButton(
onPressed: () {
Navigator.push(context,
MaterialPageRoute(builder: (context) {
return const SignUpPage();
}));
},
child: const Text("馬上註冊"))
],
)
],
),
),
),
);
}
}
在 MePage
的部分我們一樣放個登出鈕控制登出,該按鈕未來會串接後端伺服器以及清空 Shared Preferences 內的登入資訊。
class _MePageState extends State<MePage> {
bool _isLoggingOut = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('聊天主畫面'),
),
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('Hello ${MeDataLayer.of(context).user?.name}'),
const SizedBox(
height: 20,
),
_isLoggingOut
? const CircularProgressIndicator()
: OutlinedButton(
onPressed: () async {
setState(() {
_isLoggingOut = true;
});
await Me.logout();
if (context.mounted) {
MeDataLayer.of(context).setUser(null);
Navigator.pushReplacement(context,
MaterialPageRoute(builder: (context) {
return const LoginPage();
}));
}
setState(() {
_isLoggingOut = false;
});
},
child: const Text("登出"))
],
),
),
),
);
}
}
class Me {
const Me(
{required this.uid,
required this.id,
required this.name,
required this.token,
required this.profile});
final int uid;
final String id;
final String name;
final String token;
final String? profile;
static Future<Me> login(String id, String password) async {
// TODO 向伺服器發送帳密資訊以請求登入
await Future.delayed(const Duration(seconds: 3));
return Future.value(const Me(
uid: 0, id: "kahou0522", name: "日野下花帆", token: "", profile: null));
}
static Future<void> logout() async {
// TODO 清除 shared preference
await Future.delayed(const Duration(seconds: 3));
return;
}
}
class MeDataLayer extends InheritedWidget {
const MeDataLayer(
{super.key,
required this.user,
required this.setUser,
required super.child});
final Function(Me? user) setUser;
final Me? user;
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.user != user;
}
}
註冊畫面主要都是 UI 的部分,程式碼很長這裡就不放了,這個畫面我們後續會再一併處理!