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.HomeRouteis the homepage of the application.When building
MaterialApp, we configure the supported languages for the app and listen for system language change events. Additionally,MaterialAppconsumes (depends on)ThemeModelandLocaleModel, so when the app's theme or language changes,MaterialAppwill 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 ofGmLocalizationsDelegateandGmLocalizations, 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.