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 throughProvider.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
andLocaleModel
, 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 throughGmLocalizations
. For details on the implementation ofGmLocalizationsDelegate
andGmLocalizations
, 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:
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:
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.
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 :
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.