APP入口

main函数为APP入口函数,初始化完成后才会加载UI(MyApp),MyApp 是应用的入口Widget,实现如下:

  1. void main() => Global.init().then((e) => runApp(MyApp()));
  2. class MyApp extends StatelessWidget {
  3. // This widget is the root of your application.
  4. @override
  5. Widget build(BuildContext context) {
  6. return MultiProvider(
  7. providers: <SingleChildCloneableWidget>[
  8. ChangeNotifierProvider.value(value: ThemeModel()),
  9. ChangeNotifierProvider.value(value: UserModel()),
  10. ChangeNotifierProvider.value(value: LocaleModel()),
  11. ],
  12. child: Consumer2<ThemeModel, LocaleModel>(
  13. builder: (BuildContext context, themeModel, localeModel, Widget child) {
  14. return MaterialApp(
  15. theme: ThemeData(
  16. primarySwatch: themeModel.theme,
  17. ),
  18. onGenerateTitle: (context){
  19. return GmLocalizations.of(context).title;
  20. },
  21. home: HomeRoute(), //应用主页
  22. locale: localeModel.getLocale(),
  23. //我们只支持美国英语和中文简体
  24. supportedLocales: [
  25. const Locale('en', 'US'), // 美国英语
  26. const Locale('zh', 'CN'), // 中文简体
  27. //其它Locales
  28. ],
  29. localizationsDelegates: [
  30. // 本地化的代理类
  31. GlobalMaterialLocalizations.delegate,
  32. GlobalWidgetsLocalizations.delegate,
  33. GmLocalizationsDelegate()
  34. ],
  35. localeResolutionCallback:
  36. (Locale _locale, Iterable<Locale> supportedLocales) {
  37. if (localeModel.getLocale() != null) {
  38. //如果已经选定语言,则不跟随系统
  39. return localeModel.getLocale();
  40. } else {
  41. Locale locale;
  42. //APP语言跟随系统语言,如果系统语言不是中文简体或美国英语,
  43. //则默认使用美国英语
  44. if (supportedLocales.contains(_locale)) {
  45. locale= _locale;
  46. } else {
  47. locale= Locale('en', 'US');
  48. }
  49. return locale;
  50. }
  51. },
  52. // 注册命名路由表
  53. routes: <String, WidgetBuilder>{
  54. "login": (context) => LoginRoute(),
  55. "themes": (context) => ThemeChangeRoute(),
  56. "language": (context) => LanguageRoute(),
  57. },
  58. );
  59. },
  60. ),
  61. );
  62. }
  63. }
  1. 我们的根widget是MultiProvider,它将主题、用户、语言三种状态绑定到了应用的根上,如此一来,任何路由中都可以通过Provider.of()来获取这些状态,也就是说这三种状态是全局共享的!
  2. HomeRoute是应用的主页。
  3. 在构建MaterialApp时,我们配置了APP支持的语言列表,以及监听了系统语言改变事件;另外MaterialApp消费(依赖)了ThemeModel和LocaleModel,所以当APP主题或语言改变时MaterialApp会重新构建
  4. 我们注册了命名路由表,以便在APP中可以直接通过路由名跳转。
  5. 为了支持多语言(本APP中我们支持美国英语和中文简体两种语言)我们实现了一个GmLocalizationsDelegate,子Widget中都可以通过GmLocalizations来动态获取APP当前语言对应的文案。关于GmLocalizationsDelegate和GmLocalizations的实现方式读者可以参考“国际化”一章中的介绍,此处不再赘述

主页

为了简单起见,当APP启动后,如果之前已登录了APP,则显示该用户项目列表;如果之前未登录,则显示一个登录按钮,点击后跳转到登录页。另外,我们实现一个抽屉菜单,里面包含当前用户头像及APP的菜单。下面我们先看看要实现的效果,如图所示:
APP入口及主页 - 图1APP入口及主页 - 图2

我们在创建lib/routes/home_page.dart文件,实现如下:

  1. class HomeRoute extends StatefulWidget {
  2. @override
  3. _HomeRouteState createState() => _HomeRouteState();
  4. }
  5. class _HomeRouteState extends State<HomeRoute> {
  6. @override
  7. Widget build(BuildContext context) {
  8. return Scaffold(
  9. appBar: AppBar(
  10. title: Text(GmLocalizations.of(context).home),
  11. ),
  12. body: _buildBody(), // 构建主页面
  13. drawer: MyDrawer(), //抽屉菜单
  14. );
  15. }
  16. ...// 省略
  17. }

上面代码中,主页的标题(title)我们是通过来 GmLocalizations.of(context).home 获得
GmLocalizations是我们提供的一个Localizations类,用于支持多语言,因此当APP语言改变时,凡是使用GmLocalizations动态获取的文案都会是相应语言的文案,这在前面“国际化”一章中已经介绍过,读者可以前翻查阅。
我们通过 _buildBody()方法来构建主页内容,_buildBody()方法实现代码如下:

  1. Widget _buildBody() {
  2. UserModel userModel = Provider.of<UserModel>(context);
  3. if (!userModel.isLogin) {
  4. //用户未登录,显示登录按钮
  5. return Center(
  6. child: RaisedButton(
  7. child: Text(GmLocalizations.of(context).login),
  8. onPressed: () => Navigator.of(context).pushNamed("login"),
  9. ),
  10. );
  11. } else {
  12. //已登录,则展示项目列表
  13. return InfiniteListView<Repo>(
  14. onRetrieveData: (int page, List<Repo> items, bool refresh) async {
  15. var data = await Git(context).getRepos(
  16. refresh: refresh,
  17. queryParameters: {
  18. 'page': page,
  19. 'page_size': 20,
  20. },
  21. );
  22. //把请求到的新数据添加到items中
  23. items.addAll(data);
  24. // 如果接口返回的数量等于'page_size',则认为还有数据,反之则认为最后一页
  25. return data.length==20;
  26. },
  27. itemBuilder: (List list, int index, BuildContext ctx) {
  28. // 项目信息列表项
  29. return RepoItem(list[index]);
  30. },
  31. );
  32. }
  33. }

上面代码注释很清楚:如果用户未登录,显示登录按钮;如果用户已登录,则展示项目列表。这里项目列表使用了InfiniteListView Widget,它是flukit package中提供的。InfiniteListView同时支持了下拉刷新和上拉加载更多两种功能。onRetrieveData 为数据获取回调,该回调函数接收三个参数:

参数名 类型 解释
page int 当前页号
items List 保存当前列表数据的List
refresh bool 是否是下拉刷新触发

返回值类型为bool,为true时表示还有数据,为false时则表示后续没有数据了。onRetrieveData 回调中我们调用Git(context).getRepos(…)来获取用户项目列表,同时指定每次请求获取20条。当获取成功时,首先要将新获取的项目数据添加到items中,然后根据本次请求的项目条数是否等于期望的20条来判断还有没有更多的数据。

在此需要注意,Git(context).getRepos(…)方法中需要refresh参数来判断是否使用缓存itemBuilder为列表项的builder,我们需要在该回调中构建每一个列表项Widget。由于列表项构建逻辑较复杂,我们单独封装一个RepoItem Widget 专门用于构建列表项UI

新建lib/widget/RepoItem.dart文件,RepoItem 实现如下:

  1. class RepoItem extends StatefulWidget {
  2. // 将`repo.id`作为RepoItem的默认key
  3. RepoItem(this.repo) : super(key: ValueKey(repo.id));
  4. final Repo repo;
  5. @override
  6. _RepoItemState createState() => _RepoItemState();
  7. }
  8. class _RepoItemState extends State<RepoItem> {
  9. @override
  10. Widget build(BuildContext context) {
  11. var subtitle;
  12. return Padding(
  13. padding: const EdgeInsets.only(top: 8.0),
  14. child: Material(
  15. color: Colors.white,
  16. shape: BorderDirectional(
  17. bottom: BorderSide(
  18. color: Theme.of(context).dividerColor,
  19. width: .5,
  20. ),
  21. ),
  22. child: Padding(
  23. padding: const EdgeInsets.only(top: 0.0, bottom: 16),
  24. child: Column(
  25. crossAxisAlignment: CrossAxisAlignment.start,
  26. children: <Widget>[
  27. ListTile(
  28. dense: true,
  29. leading: gmAvatar(
  30. //项目owner头像
  31. widget.repo.owner.avatar_url,
  32. width: 24.0,
  33. borderRadius: BorderRadius.circular(12),
  34. ),
  35. title: Text(
  36. widget.repo.owner.login,
  37. textScaleFactor: .9,
  38. ),
  39. subtitle: subtitle,
  40. trailing: Text(widget.repo.language ?? ""),
  41. ),
  42. // 构建项目标题和简介
  43. Padding(
  44. padding: const EdgeInsets.symmetric(horizontal: 16.0),
  45. child: Column(
  46. crossAxisAlignment: CrossAxisAlignment.start,
  47. children: <Widget>[
  48. Text(
  49. widget.repo.fork
  50. ? widget.repo.full_name
  51. : widget.repo.name,
  52. style: TextStyle(
  53. fontSize: 15,
  54. fontWeight: FontWeight.bold,
  55. fontStyle: widget.repo.fork
  56. ? FontStyle.italic
  57. : FontStyle.normal,
  58. ),
  59. ),
  60. Padding(
  61. padding: const EdgeInsets.only(top: 8, bottom: 12),
  62. child: widget.repo.description == null
  63. ? Text(
  64. GmLocalizations.of(context).noDescription,
  65. style: TextStyle(
  66. fontStyle: FontStyle.italic,
  67. color: Colors.grey[700]),
  68. )
  69. : Text(
  70. widget.repo.description,
  71. maxLines: 3,
  72. style: TextStyle(
  73. height: 1.15,
  74. color: Colors.blueGrey[700],
  75. fontSize: 13,
  76. ),
  77. ),
  78. ),
  79. ],
  80. ),
  81. ),
  82. // 构建卡片底部信息
  83. _buildBottom()
  84. ],
  85. ),
  86. ),
  87. ),
  88. );
  89. }
  90. // 构建卡片底部信息
  91. Widget _buildBottom() {
  92. const paddingWidth = 10;
  93. return IconTheme(
  94. data: IconThemeData(
  95. color: Colors.grey,
  96. size: 15,
  97. ),
  98. child: DefaultTextStyle(
  99. style: TextStyle(color: Colors.grey, fontSize: 12),
  100. child: Padding(
  101. padding: const EdgeInsets.symmetric(horizontal: 16),
  102. child: Builder(builder: (context) {
  103. var children = <Widget>[
  104. Icon(Icons.star),
  105. Text(" " +
  106. widget.repo.stargazers_count
  107. .toString()
  108. .padRight(paddingWidth)),
  109. Icon(Icons.info_outline),
  110. Text(" " +
  111. widget.repo.open_issues_count
  112. .toString()
  113. .padRight(paddingWidth)),
  114. Icon(MyIcons.fork), //我们的自定义图标
  115. Text(widget.repo.forks_count.toString().padRight(paddingWidth)),
  116. ];
  117. if (widget.repo.fork) {
  118. children.add(Text("Forked".padRight(paddingWidth)));
  119. }
  120. if (widget.repo.private == true) {
  121. children.addAll(<Widget>[
  122. Icon(Icons.lock),
  123. Text(" private".padRight(paddingWidth))
  124. ]);
  125. }
  126. return Row(children: children);
  127. }),
  128. ),
  129. ),
  130. );
  131. }
  132. }

上面代码有两点需要注意:

  • 在构建项目拥有者头像时调用了gmAvatar(…)方法,该方法是是一个全局工具函数,专门用于获取头像图片,实现如下:
    1. Widget gmAvatar(String url, {
    2. double width = 30,
    3. double height,
    4. BoxFit fit,
    5. BorderRadius borderRadius,
    6. }) {
    7. var placeholder = Image.asset(
    8. "imgs/avatar-default.png", //头像占位图,加载过程中显示
    9. width: width,
    10. height: height
    11. );
    12. return ClipRRect(
    13. borderRadius: borderRadius ?? BorderRadius.circular(2),
    14. child: CachedNetworkImage(
    15. imageUrl: url,
    16. width: width,
    17. height: height,
    18. fit: fit,
    19. placeholder: (context, url) =>placeholder,
    20. errorWidget: (context, url, error) =>placeholder,
    21. ),
    22. );
    23. }
    代码中调用了CachedNetworkImage 是cached_network_image包中提供的一个Widget,它不仅可以在图片加载过程中指定一个占位图,而且还可以对网络请求的图片进行缓存,更多详情读者可以自行查阅其文档
    由于Flutter 的Material 图标库中没有fork图标,所以我们在iconfont.cn上找了一个fork图标,然后根据“图片和Icon”一节中介绍的使用自定义字体图标的方法集成到了我们的项目中

抽屉菜单


新建lib/widget/MyDrawer.dart文件
抽屉菜单分为两部分:顶部头像和底部功能菜单项。当用户未登录,则抽屉菜单顶部会显示一个默认的灰色占位图,若用户已登录,则会显示用户的头像。抽屉菜单底部有“换肤”和“语言”两个固定菜单,若用户已登录,则会多一个“注销”菜单。用户点击“换肤”和“语言”两个菜单项,会进入相应的设置页面。我们的抽屉菜单效果如图所示:
APP入口及主页 - 图3APP入口及主页 - 图4
实现代码如下:

  1. class MyDrawer extends StatelessWidget {
  2. const MyDrawer({
  3. Key key,
  4. }) : super(key: key);
  5. @override
  6. Widget build(BuildContext context) {
  7. return Drawer(
  8. //移除顶部padding
  9. child: MediaQuery.removePadding(
  10. context: context,
  11. removeTop: true,
  12. child: Column(
  13. crossAxisAlignment: CrossAxisAlignment.start,
  14. children: <Widget>[
  15. _buildHeader(), //构建抽屉菜单头部
  16. Expanded(child: _buildMenus()), //构建功能菜单
  17. ],
  18. ),
  19. ),
  20. );
  21. }
  22. Widget _buildHeader() {
  23. return Consumer<UserModel>(
  24. builder: (BuildContext context, UserModel value, Widget child) {
  25. return GestureDetector(
  26. child: Container(
  27. color: Theme.of(context).primaryColor,
  28. padding: EdgeInsets.only(top: 40, bottom: 20),
  29. child: Row(
  30. children: <Widget>[
  31. Padding(
  32. padding: const EdgeInsets.symmetric(horizontal: 16.0),
  33. child: ClipOval(
  34. // 如果已登录,则显示用户头像;若未登录,则显示默认头像
  35. child: value.isLogin
  36. ? gmAvatar(value.user.avatar_url, width: 80)
  37. : Image.asset(
  38. "imgs/avatar-default.png",
  39. width: 80,
  40. ),
  41. ),
  42. ),
  43. Text(
  44. value.isLogin
  45. ? value.user.login
  46. : GmLocalizations.of(context).login,
  47. style: TextStyle(
  48. fontWeight: FontWeight.bold,
  49. color: Colors.white,
  50. ),
  51. )
  52. ],
  53. ),
  54. ),
  55. onTap: () {
  56. if (!value.isLogin) Navigator.of(context).pushNamed("login");
  57. },
  58. );
  59. },
  60. );
  61. }
  62. // 构建菜单项
  63. Widget _buildMenus() {
  64. return Consumer<UserModel>(
  65. builder: (BuildContext context, UserModel userModel, Widget child) {
  66. var gm = GmLocalizations.of(context);
  67. return ListView(
  68. children: <Widget>[
  69. ListTile(
  70. leading: const Icon(Icons.color_lens),
  71. title: Text(gm.theme),
  72. onTap: () => Navigator.pushNamed(context, "themes"),
  73. ),
  74. ListTile(
  75. leading: const Icon(Icons.language),
  76. title: Text(gm.language),
  77. onTap: () => Navigator.pushNamed(context, "language"),
  78. ),
  79. if(userModel.isLogin) ListTile(
  80. leading: const Icon(Icons.power_settings_new),
  81. title: Text(gm.logout),
  82. onTap: () {
  83. showDialog(
  84. context: context,
  85. builder: (ctx) {
  86. //退出账号前先弹二次确认窗
  87. return AlertDialog(
  88. content: Text(gm.logoutTip),
  89. actions: <Widget>[
  90. FlatButton(
  91. child: Text(gm.cancel),
  92. onPressed: () => Navigator.pop(context),
  93. ),
  94. FlatButton(
  95. child: Text(gm.yes),
  96. onPressed: () {
  97. //该赋值语句会触发MaterialApp rebuild
  98. userModel.user = null;
  99. Navigator.pop(context);
  100. },
  101. ),
  102. ],
  103. );
  104. },
  105. );
  106. },
  107. ),
  108. ],
  109. );
  110. },
  111. );
  112. }
  113. }

用户点击“注销”,userModel.user 会被置空,此时所有依赖userModel的组件都会被rebuild,如主页会恢复成未登录的状态。
本小节我们介绍了APP入口MaterialApp的一些配置,然后实现了APP的首页。后面我们将展示登录页、换肤页、语言切换页。