lib_x 0.1.8 lib_x: ^0.1.8 copied to clipboard
lib_x is an Object-Oriented approach to Flutter for a better architecture design. It provides Solutions for Routing, State Management, Data Provider & more.
Getting Started #
In a real world application we need to structure our app to be maintainable, scalable, and to allow for collaboration between developers. And for that, we need a clear design pattern, and separation of concerns among other things.
lib_x was designed to help achieve these goals. So here is a Figma template you can use as a starting point for your next project to help you analyze and structure your app.
Also here is a tutorial article on medium.com for a more comprehensive example demonstrating data CRUDing and State Managment using lib_x
Now let's create a simple application that loads news stories from some source, and see how it should be structured properly.
Here's a visual of the app on YouTube.
After flutter create example
lib/
#
let's keep it clean and make a new folder src to be the container of the app and let's have a simple main.dart like this:
lib/main.dart
import 'package:example/src/src.dart';
void main() {
runApp(const MyApp());
}
src/:
#
we'll create a src.dart file and 3 folders.
src.dart
will serve as an index of the app's components.
lib/src/src.dart
export 'package:lib_x/lib_x.dart';
// Data
export 'data/news_list.dart';
export 'data/news_story.dart';
// Render
export 'render/const/material_app.dart';
export 'render/const/route_map.dart';
export 'render/const/theme_data.dart';
export 'render/my_app.dart';
export 'render/pages/home_page.dart';
export 'render/pages/not_found_page.dart';
export 'render/pages/story_page.dart';
// Services
export 'services/api.dart';
// and so on, all our declaration will be exported from here
-
services/
Inside this directory, we'll handle the services the application need, e.g. if the app requires authentication, we'll create a file auth.dart that will contain our auth logic. If we're dealing with firestore db, we'll create firestore.dart that deals with firestore. If dealing with firebase storage, there will be a storage.dart file... etc
#
The benefits of that: if you wanted at some point to migrate from firestore to another database service provider. All you'll need to do is making a new file for the new service that will replace firestore without touching any widgets or data models. Also it can be done by a completely different developer. they'll just see the ins and outs of the firestore service class and create the replacement to take the same inputs, and returns the expected output.
lib/src/services/api.dart
import 'package:example/src/src.dart';
// here we're going to make use of the abstract class,
// because we don't want our api to be instantiated
// it's just an encapsulation to the behaviors we need in our app
abstract class Api {
static Future<List<NewsStory>> fetchStories() async {
// load from db logic
final List<NewsStory> results = [];
// fake it till you make it
final List<Map<String, dynamic>> snapshot = List.generate(
10,
(index) => {
'id': XUtils.genId(),
'title': 'Title: ${index + 1}',
'content': 'Content of ${index + 1}',
},
);
for (var value in snapshot) {
results.add(NewsStory.fromMap(value));
}
return results;
}
}
Note: each service class should be an encapsulation for only one service. Don't make a class that does everything. So for example, don't make a class that deals with database and storage and auth, but rather a class for each service.
-
data/
#
Here will be the data concerns only. lib_x provides 2 useful types that will help with data management concerns.
StatefulData
will make the data model listenable by the views.DataProvider<T>
will make the data accessible by context in the widget tree.
lib/src/data/news_story.dart
import 'package:example/src/src.dart';
// 1- create the data model
class NewsStory extends StatefulData {
final String id;
final String title;
final String content;
bool readLater;
NewsStory({
required this.id,
required this.title,
required this.content,
this.readLater = false,
});
// it's best practice to make all the converters [from || to] class in factory method inside the class
factory NewsStory.fromMap(Map<String, dynamic> map) {
return NewsStory(
id: map['id'] as String,
title: map['title'] as String,
content: map['content'] as String,
readLater: map['readLater'] ?? false,
);
}
void toggleReadLater() {
readLater = !readLater;
NewsList.instance.updateReadLater();
update();
}
}
// 2- create the data model provider
class StoryProvider extends DataProvider<NewsStory> {
// Declare the desired data you want the provider to provide
final NewsStory story;
const StoryProvider({
super.key,
required this.story, // first argument: require the declared data object
required super.child, // second argument: require & pass child to the super class
}) : super(data: story); // pass the data to the super class
// Declare a static method that returns the provider instance of(context)
static StoryProvider of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<StoryProvider>()!;
}
// or you can do the provider like this
class StoryProvider extends DataProvider<NewsStory> {
// Declare the desired data as data
final NewsStory data;
const StoryProvider({
super.key,
required super.data, // first argument: require & pass the data object to the super class
required super.child, // second argument: require & pass child to the super class
});
// Declare a static method that returns the provider instance of(context)
static StoryProvider of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<StoryProvider>()!;
}
lib/src/data/news_list.dart
import 'package:example/src/src.dart';
// here instead of doing a provider class,
// we're goona explore a useful technique "singleton class",
// because we don't want NewsList to be instantiated more than once.
class NewsList extends StatefulData {
// declare a private constructor
NewsList._constructor();
// assign it to a static final private variable
static final NewsList _this = NewsList._constructor();
// create a public getter that returns the same final object everytime
static NewsList get instance => _this;
final List<NewsStory> newsList = [];
final List<NewsStory> readLaterList = [];
static late TabController tabController;
void loadNewsStories() async {
final List<NewsStory> stories = await Api.fetchStories();
newsList.addAll(stories);
update();
}
void updateReadLater() async {
readLaterList.clear();
final List<NewsStory> stories = await Future.value(
newsList.where((story) => story.readLater).toList(),
);
readLaterList.addAll(stories);
update();
}
NewsStory getById(String id) => newsList.singleWhere((story) => story.id == id);
}
Notes:
-
It's best to declare the provider of a type in the same file with its declaration. So, all the concerns of that type are in the same file. Single Source Of Truth files.
-
If there's a lot of data types, it best to group related types in sub-directories.. and so on.
-
render/ #
Here will be all the rendering concerns, separated from the data management, and services implementations. And again, we'll group and classify the views elements into sub-directories. So let's plan our steps to implement the views components.
1. First step: Define MyApp and its dependencies. #
my_app.dart #
which will contain MaterialApp
, the entery point and first concern for rendering the app.
lib/src/render/my_app.dart
import 'package:example/src/src.dart';
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
// here we'll load the stories data when app starts building
NewsList.instance.loadNewsStories();
return MaterialX(
materialApp: materialApp,
routeMap: routeMap,
);
}
}
render/const/ #
in this directory will have the constant definitions the app need. And for a minimal app, it'll be something like this:
lib/src/render/const/theme_data.dart
// themes and color scheme of the app.
const Color lightC = Color.fromARGB(255, 230, 226, 247);
const Color lightC1 = Color.fromARGB(255, 241, 239, 253);
const Color darkC = Color.fromARGB(255, 39, 37, 54);
const Color darkC1 = Color.fromARGB(255, 47, 45, 69);
const Color primaryC = Color.fromARGB(255, 255, 0, 212);
final ThemeData myLightTheme = ThemeData.light().copyWith(
visualDensity: VisualDensity.adaptivePlatformDensity,
primaryColor: primaryC,
scaffoldBackgroundColor: lightC,
canvasColor: transparent,
);
final ThemeData myDarkTheme = ThemeData.dark().copyWith(
visualDensity: VisualDensity.adaptivePlatformDensity,
primaryColor: primaryC,
scaffoldBackgroundColor: darkC,
canvasColor: transparent,
);
// some default shadow
final List<BoxShadow> myShadow = [
BoxShadow(
color: black.withOpacity(0.15),
spreadRadius: 5,
blurRadius: 8,
offset: const Offset(2, 2),
),
];
// some default BorderRadius
final BorderRadius semiRounded = borderRadius(15);
final BorderRadius rounded = borderRadius(200);
lib/src/render/const/route_map.dart
// routes consts & map
import 'package:example/src/src.dart';
// Root = '/'; // predefined in lib_x
// always define paths to avoid mistakes
const String StoryPath = '/story/:id'; // path pattern
// functional string path
String storyPath(String id) => '/story/$id';
final RouteMap routeMap = RouteMap(
routes: {
Root: (RouteData info) => const MaterialPage(child: HomePage()), // ToDo: declare
StoryPath: (RouteData info) =>
MaterialPage(child: StoryPage(id: info.pathParameters['id']!)), // ToDo: declare
},
onUnknownRoute: (_) => const MaterialPage(child: NotFoundPage()),
);
lib/src/render/const/material_app.dart
import 'package:example/src/src.dart';
final MaterialApp materialApp = MaterialApp(
title: 'Example App',
debugShowCheckedModeBanner: false,
theme: myLightTheme,
darkTheme: myDarkTheme,
);
2. Second step: Defining the pages we promised in the route_map. #
lib/src/render/pages/home_page.dart
import 'package:example/src/src.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage>
with SingleTickerProviderStateMixin {
@override
void initState() {
super.initState();
// we need to asign the tabController in order to use it
NewsList.tabController = TabController(vsync: this, length: 2);
}
@override
Widget build(BuildContext context) {
return ScaffoldX(
appBar: const HomeAppBar(), // ToDo: declare
body: TabBarView(
controller: NewsList.tabController,
children: const [
StoriesListView(), // ToDo: declare
ReadLaterListView(), // ToDo: declare
],
),
);
}
}
lib/src/render/layout/app_bar.dart - add to src.dart
import 'package:example/src/src.dart';
class HomePageAppBar extends StatelessWidget {
const HomePageAppBar({super.key});
@override
Widget build(BuildContext context) {
final ValueController<int> indexController =
ValueController<int>(NewsList.tabController.index);
NewsList.tabController.addListener(() {
indexController.update(NewsList.tabController.index);
});
return ReactiveBuilder(
controller: indexController,
builder: (index) {
return ReactiveBuilder(
controller: X.themeMode,
builder: (themeMode) {
return AppBar(
backgroundColor: themeMode == ThemeMode.dark ? darkC1 : lightC1,
bottom: TabBar(
controller: NewsList.tabController,
indicatorColor: primaryC,
tabs: [
Tab(
icon: Icon(
Icons.menu_rounded,
color: index == 0 ? primaryC : black,
),
),
Tab(
icon: Icon(
Icons.schedule,
color: index == 1 ? primaryC : black,
),
),
],
),
);
},
);
},
);
}
}
class StoryPageAppBar extends StatelessWidget {
const StoryPageAppBar({super.key});
@override
Widget build(BuildContext context) {
return ReactiveBuilder(
controller: X.themeMode,
builder: (themeMode) {
return AppBar(
backgroundColor: themeMode == ThemeMode.dark ? darkC1 : lightC1,
leading: const BackButton(color: primaryC),
);
},
);
}
}
lib/src/render/lists/stories_list.dart - add to src.dart
import 'package:example/src/src.dart';
class StoriesListView extends StatelessWidget {
const StoriesListView({super.key});
@override
Widget build(BuildContext context) {
final NewsList newsController = NewsList.instance;
return ReBuilder(
controller: newsController,
builder: () {
return ListView.separated(
padding: const EdgeInsets.all(10),
separatorBuilder: (_, i) => const SizedBox(height: 10),
itemCount: newsController.newsList.length,
itemBuilder: (context, index) {
return StoryProvider(
story: newsController.newsList[index],
child: const StoryCard(),
);
},
);
},
);
}
}
lib/src/render/lists/read_later_list.dart - add to src.dart
import 'package:example/src/src.dart';
class ReadLaterListView extends StatelessWidget {
const ReadLaterListView({super.key});
@override
Widget build(BuildContext context) {
final NewsList newsController = NewsList.instance;
return ReBuilder(
controller: newsController,
builder: () {
return ListView.separated(
padding: const EdgeInsets.all(10),
separatorBuilder: (_, i) => const SizedBox(height: 10),
itemCount: newsController.readLaterList.length,
itemBuilder: (context, index) {
return StoryProvider(
story: newsController.readLaterList[index],
child: const StoryCard(),
);
},
);
},
);
}
}
lib/src/render/cards/story_card.dart - add to src.dart
import 'package:example/src/src.dart';
class StoryCard extends StatelessWidget {
const StoryCard({super.key});
@override
Widget build(BuildContext context) {
final NewsStory story = StoryProvider.of(context).story;
return GestureDetector(
onTap: () => X.to(path: storyPath(story.id)),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: darkC1,
boxShadow: myShadow,
borderRadius: semiRounded,
),
child: Column(
children: [
const SizedBox(height: 20),
Text(story.title),
ReBuilder(
controller: story,
builder: () {
final bool added = story.readLater;
return TextButton(
onPressed: () => story.toggleReadLater(),
child: Text(
added ? 'Remove from read later' : 'Add to read later'),
);
},
),
],
),
),
);
}
}
lib/src/render/pages/story_page.dart
import 'package:example/src/src.dart';
class StoryPage extends StatelessWidget {
final String id;
const StoryPage({super.key, required this.id});
@override
Widget build(BuildContext context) {
final NewsStory story = NewsList.instance.getById(id);
return StoryProvider(
story: story,
child: ScaffoldX(
appBar: const StoryPageAppBar(),
body: Center(
child: Container(
padding: const EdgeInsets.all(40),
decoration: BoxDecoration(
color: darkC1,
boxShadow: myShadow,
borderRadius: semiRounded,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('id: $id'),
/// notice we're not passing any data, because we have a provider
const StoryTitle(),
const StoryContent(),
],
),
),
),
),
);
}
}
class StoryTitle extends StatelessWidget {
const StoryTitle({super.key});
@override
Widget build(BuildContext context) {
final NewsStory story = StoryProvider.of(context).story;
return Text(story.title);
}
}
class StoryContent extends StatelessWidget {
const StoryContent({super.key});
@override
Widget build(BuildContext context) {
final NewsStory story = StoryProvider.of(context).story;
return Text(story.content);
}
}
I just realized I passed the 500 line, which is ironic cause no one will read this.
P.S. #
- Structure does matter.
- Try as possible to make your files describable as A Single Source Of Truth.
- Naming should be semantic and self-explanatory, even if you're working solo. It'll save you a lot of time when you want to debug or update something later.