Flutter (102): APP entrance and home page

Time: Column:Mobile & Frontend views:242

1 APP Entry Point

The main function serves as the entry point for the app, implemented as follows:

void main() => Global.init().then((e) => runApp(MyApp()));

UI (MyApp) will only be loaded after initialization is complete. MyApp is the entry widget of the application, implemented as follows:

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => ThemeModel()),
        ChangeNotifierProvider(create: (_) => UserModel()),
        ChangeNotifierProvider(create: (_) => LocaleModel()),
      ],
      child: Consumer2<ThemeModel, LocaleModel>(
        builder: (BuildContext context, themeModel, localeModel, child) {
          return MaterialApp(
            theme: ThemeData(
              primarySwatch: themeModel.theme,
            ),
            onGenerateTitle: (context) {
              return GmLocalizations.of(context).title;
            },
            home: HomeRoute(),
            locale: localeModel.getLocale(),
            // We only support American English and Simplified Chinese
            supportedLocales: [
              const Locale('en', 'US'), // American English
              const Locale('zh', 'CN'), // Simplified Chinese
              // Other locales
            ],
            localizationsDelegates: [
              // Localization delegate classes
              GlobalMaterialLocalizations.delegate,
              GlobalWidgetsLocalizations.delegate,
              GmLocalizationsDelegate()
            ],
            localeResolutionCallback: (_locale, supportedLocales) {
              if (localeModel.getLocale() != null) {
                // If a language has already been selected, do not follow the system
                return localeModel.getLocale();
              } else {
                // Follow the system language
                Locale locale;
                if (supportedLocales.contains(_locale)) {
                  locale = _locale!;
                } else {
                  // If the system language is neither Simplified Chinese nor American English, default to American English
                  locale = Locale('en', 'US');
                }
                return locale;
              }
            },
            // Register the routing table
            routes: <String, WidgetBuilder>{
              "login": (context) => LoginRoute(),
              "themes": (context) => ThemeChangeRoute(),
              "language": (context) => LanguageRoute(),
            },
          );
        },
      ),
    );
  }
}

In the above code:

  • Our root widget is MultiProvider, which binds three states: theme, user, and language to the root of the application. This allows these states to be accessed globally through Provider.of() in any route.

  • HomeRoute is the homepage of the application.

  • When building MaterialApp, we configure the supported languages for the app and listen for system language change events. Additionally, MaterialApp consumes (depends on) ThemeModel and LocaleModel, so when the app's theme or language changes, MaterialApp will rebuild.

  • We register named routes so that we can directly navigate through route names in the app.

  • To support multiple languages (this app supports American English and Simplified Chinese), we implemented a GmLocalizationsDelegate. Sub-widgets can dynamically retrieve the current language text through GmLocalizations. For details on the implementation of GmLocalizationsDelegate and GmLocalizations, readers can refer to the "Internationalization" chapter, which is not covered here.

2 Homepage

For simplicity, when the app starts, if the user has previously logged in, it displays the user's project list; if not logged in, it shows a login button that navigates to the login page when clicked. Additionally, we implement a drawer menu containing the current user's avatar and the app's menu. Below we will first look at the expected effect, as shown in Figures:

Flutter (102): APP entrance and home page

We create a file named home_page.dart under "lib/routes" and implement it as follows:

class HomeRoute extends StatefulWidget {
  @override
  _HomeRouteState createState() => _HomeRouteState();
}

class _HomeRouteState extends State<HomeRoute> {
  static const loadingTag = "##loading##"; // Footer tag
  var _items = <Repo>[Repo()..name = loadingTag];
  bool hasMore = true; // Whether there is more data
  int page = 1; // Current page request
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(GmLocalizations.of(context).home),
      ),
      body: _buildBody(), // Build main page
      drawer: MyDrawer(), // Drawer menu
    );
  }
  ... // Omitted
}

In the code above, the title of the homepage is obtained through GmLocalizations.of(context).home. GmLocalizations is a localization class we provide to support multiple languages, so when the app language changes, any text dynamically retrieved using GmLocalizations will reflect the corresponding language. This was introduced in the previous "Internationalization" chapter, which readers can refer back to.

We build the main page content through the _buildBody() method, which is implemented as follows:

Widget _buildBody() {
  UserModel userModel = Provider.of<UserModel>(context);
  if (!userModel.isLogin) {
    // User is not logged in, show login button
    return Center(
      child: ElevatedButton(
        child: Text(GmLocalizations.of(context).login),
        onPressed: () => Navigator.of(context).pushNamed("login"),
      ),
    );
  } else {
    // User is logged in, show project list
    return ListView.separated(
      itemCount: _items.length,
      itemBuilder: (context, index) {
        // If at the footer
        if (_items[index].name == loadingTag) {
          // Less than 100 items, continue to fetch data
          if (hasMore) {
            // Fetch data
            _retrieveData();
            // Show loading during fetching
            return Container(
              padding: const EdgeInsets.all(16.0),
              alignment: Alignment.center,
              child: SizedBox(
                width: 24.0,
                height: 24.0,
                child: CircularProgressIndicator(strokeWidth: 2.0),
              ),
            );
          } else {
            // Already loaded 100 items, do not fetch more.
            return Container(
              alignment: Alignment.center,
              padding: EdgeInsets.all(16.0),
              child: Text(
                "No more data",
                style: TextStyle(color: Colors.grey),
              ),
            );
          }
        }
        // Show item in the list
        return RepoItem(_items[index]);
      },
      separatorBuilder: (context, index) => Divider(height: .0),
    );
  }
}

The comments in the above code are clear: if the user is not logged in, show the login button; if the user is logged in, display the project list.

The _retrieveData() method is used to fetch the project list, with the specific logic being: each request fetches 20 items, and when successful, it first checks if there is more data (based on whether the number of items fetched equals the expected 20). Then it adds the newly fetched data to _items and updates the state. The specific code is as follows:

// Fetch data
void _retrieveData() async {
  var data = await Git(context).getRepos(
    queryParameters: {
      'page': page,
      'page_size': 20,
    },
  );
  // If the returned data is less than the specified number, it indicates no more data; otherwise, there is more
  hasMore = data.length > 0 && data.length % 20 == 0;
  // Add the newly requested data to items
  setState(() {
    _items.insertAll(_items.length - 1, data);
    page++;
  });
}

Note that the Git(context).getRepos(...) method requires a refresh parameter to determine whether to use cached data.

The itemBuilder is the builder for the list items, where we need to construct each list item Widget in this callback. Given the complexity of the list item construction logic, we encapsulate a separate RepoItem Widget specifically for building the list item UI. The implementation of RepoItem is as follows:

import '../index.dart';

class RepoItem extends StatefulWidget {
  // Using `repo.id` as the default key for RepoItem
  RepoItem(this.repo) : super(key: ValueKey(repo.id));

  final Repo repo;

  @override
  _RepoItemState createState() => _RepoItemState();
}

class _RepoItemState extends State<RepoItem> {
  @override
  Widget build(BuildContext context) {
    var subtitle;
    return Padding(
      padding: const EdgeInsets.only(top: 8.0),
      child: Material(
        color: Colors.white,
        shape: BorderDirectional(
          bottom: BorderSide(
            color: Theme.of(context).dividerColor,
            width: .5,
          ),
        ),
        child: Padding(
          padding: const EdgeInsets.only(top: 0.0, bottom: 16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              ListTile(
                dense: true,
                leading: gmAvatar(
                  // Project owner's avatar
                  widget.repo.owner.avatar_url,
                  width: 24.0,
                  borderRadius: BorderRadius.circular(12),
                ),
                title: Text(
                  widget.repo.owner.login,
                  textScaleFactor: .9,
                ),
                subtitle: subtitle,
                trailing: Text(widget.repo.language ?? '--'),
              ),
              // Build project title and description
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 16.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text(
                      widget.repo.fork
                          ? widget.repo.full_name
                          : widget.repo.name,
                      style: TextStyle(
                        fontSize: 15,
                        fontWeight: FontWeight.bold,
                        fontStyle: widget.repo.fork
                            ? FontStyle.italic
                            : FontStyle.normal,
                      ),
                    ),
                    Padding(
                      padding: const EdgeInsets.only(top: 8, bottom: 12),
                      child: widget.repo.description == null
                          ? Text(
                              GmLocalizations.of(context).noDescription,
                              style: TextStyle(
                                  fontStyle: FontStyle.italic,
                                  color: Colors.grey[700]),
                            )
                          : Text(
                              widget.repo.description!,
                              maxLines: 3,
                              style: TextStyle(
                                height: 1.15,
                                color: Colors.blueGrey[700],
                                fontSize: 13,
                              ),
                            ),
                    ),
                  ],
                ),
              ),
              // Build card bottom information
              _buildBottom()
            ],
          ),
        ),
      ),
    );
  }

  // Build card bottom information
  Widget _buildBottom() {
    const paddingWidth = 10;
    return IconTheme(
      data: IconThemeData(
        color: Colors.grey,
        size: 15,
      ),
      child: DefaultTextStyle(
        style: TextStyle(color: Colors.grey, fontSize: 12),
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16),
          child: Builder(builder: (context) {
            var children = <Widget>[
              Icon(Icons.star),
              Text(" " +
                  widget.repo.stargazers_count
                      .toString()
                      .padRight(paddingWidth)),
              Icon(Icons.info_outline),
              Text(" " +
                  widget.repo.open_issues_count
                      .toString()
                      .padRight(paddingWidth)),
              Icon(MyIcons.fork), // Our custom icon
              Text(widget.repo.forks_count.toString().padRight(paddingWidth)),
            ];

            if (widget.repo.fork) {
              children.add(Text("Forked".padRight(paddingWidth)));
            }

            if (widget.repo.private == true) {
              children.addAll(<Widget>[
                Icon(Icons.lock),
                Text(" private".padRight(paddingWidth))
              ]);
            }
            return Row(children: children);
          }),
        ),
      ),
    );
  }
}

Note two points in the above code:

  1. When building the project owner's avatar, the gmAvatar(...) method is called, which is a global utility function specifically for fetching avatar images. Its implementation is as follows:

Widget gmAvatar(String url, {
  double width = 30,
  double? height,
  BoxFit? fit,
  BorderRadius? borderRadius,
}) {
  var placeholder = Image.asset(
      "imgs/avatar-default.png", // Avatar placeholder image
      width: width,
      height: height
  );
  return ClipRRect(
    borderRadius: borderRadius ?? BorderRadius.circular(2),
    child: CachedNetworkImage(
      imageUrl: url,
      width: width,
      height: height,
      fit: fit,
      placeholder: (context, url) => placeholder,
      errorWidget: (context, url, error) => placeholder,
    ),
  );
}

The code calls CachedNetworkImage, which is a Widget provided by the cached_network_image package. It allows specifying a placeholder image during loading and caching images fetched from the network; readers can refer to its documentation for more details.

  1. Since the Flutter Material icon library does not include a fork icon, we found one on iconfont.cn and integrated it into our project using the method described in section "3.3 Images and Icons" for custom font icons.

3 Drawer Menu

The drawer menu consists of two parts: the top avatar and the bottom functional menu items. If the user is not logged in, a default gray placeholder image is displayed at the top of the drawer menu; if logged in, the user's avatar is shown. The bottom of the drawer menu contains fixed items for "Change Skin" and "Language." If the user is logged in, there will be an additional "Logout" menu. When users click on "Change Skin" or "Language," they are directed to the corresponding settings page. The effects of our drawer menu are shown in Figures :

Flutter (102): APP entrance and home page

class MyDrawer extends StatelessWidget {
  const MyDrawer({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Drawer(
      child: MediaQuery.removePadding(
        context: context,
        // Remove top padding.
        removeTop: true,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            _buildHeader(), // Build the drawer menu header
            Expanded(child: _buildMenus()), // Build functional menu
          ],
        ),
      ),
    );
  }

  Widget _buildHeader() {
    return Consumer<UserModel>(
      builder: (BuildContext context, UserModel value, Widget? child) {
        return GestureDetector(
          child: Container(
            color: Theme.of(context).primaryColor,
            padding: EdgeInsets.only(top: 40, bottom: 20),
            child: Row(
              children: <Widget>[
                Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 16.0),
                  child: ClipOval(
                    // If logged in, show user's avatar; otherwise, show default avatar
                    child: value.isLogin
                        ? gmAvatar(value.user!.avatar_url, width: 80)
                        : Image.asset(
                            "imgs/avatar-default.png",
                            width: 80,
                          ),
                  ),
                ),
                Text(
                  value.isLogin
                      ? value.user!.login
                      : GmLocalizations.of(context).login,
                  style: TextStyle(
                    fontWeight: FontWeight.bold,
                    color: Colors.white,
                  ),
                )
              ],
            ),
          ),
          onTap: () {
            if (!value.isLogin) Navigator.of(context).pushNamed("login");
          },
        );
      },
    );
  }

  // Build menu items
  Widget _buildMenus() {
    return Consumer<UserModel>(
      builder: (BuildContext context, UserModel userModel, Widget? child) {
        var gm = GmLocalizations.of(context);
        return ListView(
          children: <Widget>[
            ListTile(
              leading: const Icon(Icons.color_lens),
              title: Text(gm.theme),
              onTap: () => Navigator.pushNamed(context, "themes"),
            ),
            ListTile(
              leading: const Icon(Icons.language),
              title: Text(gm.language),
              onTap: () => Navigator.pushNamed(context, "language"),
            ),
            if (userModel.isLogin)
              ListTile(
                leading: const Icon(Icons.power_settings_new),
                title: Text(gm.logout),
                onTap: () {
                  showDialog(
                    context: context,
                    builder: (ctx) {
                      // Show a confirmation dialog before logging out
                      return AlertDialog(
                        content: Text(gm.logoutTip),
                        actions: <Widget>[
                          TextButton(
                            child: Text(gm.cancel),
                            onPressed: () => Navigator.pop(context),
                          ),
                          TextButton(
                            child: Text(gm.yes),
                            onPressed: () {
                              // This assignment will trigger MaterialApp rebuild
                              userModel.user = null;
                              Navigator.pop(context);
                            },
                          ),
                        ],
                      );
                    },
                  );
                },
              ),
          ],
        );
      },
    );
  }
}

When the user clicks "Logout," userModel.user will be set to null. At this point, all components dependent on userModel will be rebuilt, and the home page will revert to the logged-out state.

In this section, we introduced some configurations for the APP entry MaterialApp and then implemented the APP's homepage.