This is a starter project which consist of basic components that are commonly used across all project, eg: multiple flavoring/environment, Firebase Integration, API call, localization and more. This project is adapting MVVM (Model-View-ViewModel) as the principle architecture, as well as using Provider for state management.
- This project contains 3 flavors:
- development
- staging
- production
- To run the desired flavor either use the launch configuration in VSCode/Android Studio or use the following commands:
# Development
$ flutter run --flavor development --target lib/main_development.dart
# Staging
$ flutter run --flavor staging --target lib/main_staging.dart
# Production
$ flutter run --flavor production --target lib/main_production.dart
- To make configurations/options based on flavors, add/update configs in
app_options.dart
class atlib/app/assets/
directory
class DevelopmentConstant {
static const String API_ENDPOINT = 'YOUR API URL';
}
class StagingConstant {
static const String API_ENDPOINT = 'YOUR API URL';
}
class ProductionConstant {
static const String API_ENDPOINT = 'YOUR API URL';
}
DumbDumb Flutter App works on iOS, Android
In this project, MVVM, aka Model-View-ViewModel is adapted as the base project architecture pattern. MVVM is useful to move business logic from view to ViewModel and Model. ViewModel is the mediator between View and Model which carry all user events and return back the result. To learn more, may refer to MVVM by Tech Madness for a more detailed explaination.
In summary, core idea/components for MVVM in this starter project are:
- Model - Represent the source of data, this layer mainly communicate with ViewModel layer for data fetching/api call/data validation
- ViewModel - Act as the mediator between View and Model, which accept all the user events and request and forwarding to Model for data. Once the Model has data then it returns to ViewModel and then ViewModel notify that data to View.
- View - This is the layer where widgets/layout is presenting the data to user, for any user action/requests, it will forward to ViewModel layer and get updated once job completed in ViewModel and Model layer.
-
Model data class is defining the structure of data to be used
class TokenModel { TokenModel({this.accessToken, this.refreshToken}); String? accessToken; String? refreshToken; }
-
Service class is defining the web API services
lib/app/service/base_services.dart
is provided to unified the api request instance, including user authorization session with JWT authentication.- New service should extends BaseServices to inherit the basic unified features included.
class UserServices extends BaseServices { Future<MyResponse> login(String username, String password) async { String path = '${apiUrl()}/login'; var postBody = { 'username': username, 'password': password }; return callAPI(HttpRequestType.POST, path, postBody: postBody); } }
-
Repository class is defining the business logic for accessing data source, eg: getting data from multiple source and compiled as one data type before passing back to ViewModel.
class UserRepository { UserServices _userServices = UserServices(); Future<MyResponse> login(String username, String password) async { return await _userServices.login(username, password); } }
- ViewModel class as a connector between View and Model, separating View and Model to segregate business logic from UI, by accepting all request from View and perform related request through Model Layer.
- One ViewModel class may serve multiple View classes. (ensuring Extensibility and Maintainability)
lib/app/viewmodel/base_view_model.dart
class is provided to unified common action required, eg: notify(), notifyUrgent() and more.- New ViewModel classes should extends BaseViewModel to inherit the basic unified features included.
class LoginViewModel extends BaseViewModel { Future<void> login(String username, String password) async { notify(MyResponse.loading()); response = await UserRepository().login(username, password); notify(response); }
- View layer are the presentation layer, where include all the UI classes, eg: Widgets, Pages
lib/app/view/base_stateful_page.dart
andlib/app/view/base_stateless_page.dart
is provided to unified common UI behaviour and UI presentation logic across all screen in the app.- New View classes should extends BaseStatefulPage or BaseStatelessPage to inherit the basic unified features included.
class LoginPage extends BaseStatefulPage { @override State<StatefulWidget> createState() => _LoginPageState(); } class _LoginPageState extends BaseStatefulState<LoginPage> { @override Widget body() { MyResponse myResponse = Provider.of<LoginViewModel>(context).response; return Center(child: userInfoText(myResponse)); } @override Widget floatingActionButton() { return FloatingActionButton( onPressed: () => Provider.of<LoginViewModel>(context, listen: false).login("60161234567", "Abcd1234"), child: const Icon(Icons.login), ); } }
This project relies on Provider which taking the Official_Simple app state management as base reference. Provider is use along with MVVM architectural pattern to provide better separation and management within the project.
Core concepts in Provider:
- To simplified and standardize the usage of Provider in this project, a base class, base_view_model.dart is provided which extending the ChangeNotifier and include common functions/fields required. (eg: notify(), notifyUrgent() and more)
- ChangeNotifierProviders are implemented in the top inheritance level of the project (app.dart) which using MultiProvider to support multiple providers within the project.
- For any new ViewModel class/Provider, please register in the
lib/app/asset/app_options.dart
List<SingleChildWidget> providerAssets() => [
ChangeNotifierProvider.value(value: BaseViewModel()),
ChangeNotifierProvider.value(value: LoginViewModel())
];
- To access provider values:
MyResponse myResponse = Provider.of<LoginViewModel>(context).response;
- To access provider without listen for changes:
Provider.of<LoginViewModel>(context, listen: false).login("60161234567", "Abcd1234")
This project using router to navigating between screens and handling deep links. go_router package is used which can help to parse the route path and configure the Navigator whenever the app receives a new deep link.
lib/app/assets/router/app_router.dart
is the main class to provide the configuration of the routes.- For any new screens or new routes, you may add in the GoRoute object into the GoRouter constructor.
To configure a GoRoute
, a path template and builder must be provided. Specifiy a path template to handle by providing a path
parameter, and a builder by providing either the builder
or pageBuulder
parameter:
final GoRouter router = GoRouter(routes: [
GoRoute(path: '/login', builder: (context, state) => LoginPage())
]);
A matched route can result in more than one screen being displayed on a Nvigator. This is equivalent to calling `push()', where a new screen is displayed above the previous screen with a transition animation.
To display a screen on top of another, add a child route by adding it to the parent route's `routes' list:
final GoRouter router = GoRouter(routes: [
GoRoute(path: '/login', builder: (context, state) => LoginPage()),
GoRoute(path: 'profile', builder: (context, state) => HomePage(initialIndex: 4), routes: [
GoRoute(
path: 'editProfile',
builder: (context, state) => EditBasicInfoPage(),
routes: [
GoRoute(path: 'changePhoneNumber', builder: (context, state) => ChangePhoneNumberPage())]),
GoRoute(path: 'changeLanguage', builder: (context, state) => LanguageListPage())
])
]);
Navigating to a destination in GoRouter will replace the current stack of screens with the screens configured to be displayed for the destination route. To change to a new screen, call context.go()
with a URL:
context.go('/login');
GoRouter can push a screen onto the Navigator's history stack using context.push()
, and can pop the current screen via context.pop()
. However, imperative navigation is known to cause issues with the browser history.
You can wait for a value to be returned from the destination:
Initial page:
await context.go('/login');
if(result...) ...
Returning page:
context.pop(someResult);
This project integrated Firebase product such as Firebase Cloud Messaging, Analytics, as well as Crashlytic for app analytic and performance monitoring. The integration of Firebase components are following the Add Firebase to your Flutter app.
- To update configuration key and identifiers, look for firebase_options.dart class and update the respective configuration accordingly.
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'YOUR API KEY',
appId: 'YOUR APP ID',
messagingSenderId: 'MESSAGING SENDER ID',
projectId: 'YOUR PROJECT ID',
storageBucket: 'YOUR STORAGE BUCKET',
);
static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'YOUR API KEY',
appId: 'YOUR APP ID',
messagingSenderId: 'MESSAGING SENDER ID',
projectId: 'YOUR PROJECT ID',
storageBucket: 'YOUR STORAGE BUCKET',
);
static const FirebaseOptions ios_stag = FirebaseOptions(
apiKey: 'YOUR API KEY',
appId: 'YOUR APP ID',
messagingSenderId: 'MESSAGING SENDER ID',
projectId: 'YOUR PROJECT ID',
storageBucket: 'YOUR STORAGE BUCKET',
);
static const FirebaseOptions ios_dev = FirebaseOptions(
apiKey: 'YOUR API KEY',
appId: 'YOUR APP ID',
messagingSenderId: 'MESSAGING SENDER ID',
projectId: 'YOUR PROJECT ID',
storageBucket: 'YOUR STORAGE BUCKET',
);
- A basic notification_handler.dart is included within the project, which handled Firebase initialization and receiving message in foreground+background.
- Further action when receiving message can be configured:
FirebaseMessaging.onMessage.listen((RemoteMessage message) { });
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async { }
This project relies on flutter_localizations and follows the official internationalization guide for Flutter.
- To add a new localizable string, open the
app_en.arb
file atlib/l10n/arb/app_en.arb
.
{
"@@locale": "en",
"counterAppBarTitle": "Counter",
"@counterAppBarTitle": {
"description": "Text shown in the AppBar of the Counter Page"
}
}
- Then add a new key/value and description
{
"@@locale": "en",
"counterAppBarTitle": "Counter",
"@counterAppBarTitle": {
"description": "Text shown in the AppBar of the Counter Page"
},
"helloWorld": "Hello World",
"@helloWorld": {
"description": "Hello World Text"
}
}
- Use the new string
import 'package:dumbdumb_flutter_app/l10n/l10n.dart';
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Text(l10n.helloWorld);
}
Update the CFBundleLocalizations
array in the Info.plist
at ios/Runner/Info.plist
to include the new locale.
...
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>es</string>
</array>
...
- For each supported locale, add a new ARB file in
lib/l10n/arb
.
βββ l10n
β βββ arb
β β βββ app_en.arb
β β βββ app_es.arb
- Add the translated strings to each
.arb
file:
app_en.arb
{
"@@locale": "en",
"counterAppBarTitle": "Counter",
"@counterAppBarTitle": {
"description": "Text shown in the AppBar of the Counter Page"
}
}
app_es.arb
{
"@@locale": "es",
"counterAppBarTitle": "Contador",
"@counterAppBarTitle": {
"description": "Texto mostrado en la AppBar de la pΓ‘gina del contador"
}
}
A big thanks and appreciation to the good work from very_good_cli teams, as this project is taking base reference from the sample project created using very_good_cli. We learn a lot from their outstanding "very good core" which support multi flavoring, localization and many more out of the box. In this dumbdumb project, we are slimming down the package, applying various mod and elements on top of it, to make it as an more product oriented and ready to use starter pack for Flutter project.