This is part of the Same App, Different Tech project.
It contains the same simple but non-trivial mobile app implemented using a variety of different tech stacks.
-
It helps you learn backend development with Celest, using Dart.
-
Feel free to clone this repository as a foundation for your own mobile apps using Celest as the backend. It's a starting point for clean, well organized, well documented code, which is easy to understand, develop, refactor, change, maintain and test.
-
Using Celest for backend development with Dart, including cloud-functions and authentication.
-
How to test the app.
-
Uncoupling the backend communication by using a DAO (Data Access Object) pattern, featuring:
- Using a "fake" backend for development and testing
- Using a "real" backend for production
- On-demand fetching (REST get), or continuous streaming (websocket)
-
And also:
- State management.
- Theming, and changing between light and dark modes.
- Localization (translations).
- Saving data to the local device storage.
- Configuring the app.
- Organizing the app directories.
This is the app:
Clone the app to your computer, and open it in your IDE (e.g. IntelliJ, Android Studio, VSCode).
Before running the app, you need to start the Celest service. To do that, open a terminal and run:
celest start
The start
command will run Celest locally, in your machine.
Alternatively, to deploy the app to your Celest account in the cloud, try instead:
celest deploy
The deploy
command will run Celest in the cloud.
The structure below represents a mobile app using Flutter and Celest:
mobile_app/
├── celest/ # Local Celest package
| ├── functions/ # Backend only code
| ├── lib/ # Shared code between frontend and backend
| ├── test/
| └── pubspec.yaml # For the local Celest package
├── lib/ # Frontend only code
├── test/
└── pubspec.yaml # For the frontend. Includes the local Celest package
Upon executing celest start
, a local Dart package named celest_backend
is generated within your
application's directory, in the celest subdirectory. This local package contains
its own pubspec.yaml
, analysis_options.yaml
etc.
The celest_backend
package is integrated into your app via the app's
own pubspec.yaml file,
which points to the celest
directory. The dependency is specified as follows:
dependencies:
celest_backend:
path: celest/
As a result, your frontend app code in the lib directory can import and use the backend code that's present in the celest/lib directory; But the backend code in the celest/lib cannot see the frontend code you added in your app's lib directory.
For this reason, all code that you want to share between the backend and the frontend should be put into the celest/lib directory.
On the other hand, the celest/functions directory (which is
outside celest/lib) cannot be seen by any of those: You cannot import it
from files in lib, and you cannot import it from files in celest/lib.
These files in the celest/functions directory are to be used exclusively by the
Celest service (started with celest start
), which will use them as a base to auto-generate some
code inside the celest/lib directory.
To sum up:
-
celest/lib
- Accessible from your app's
lib
directory. This means that files incelest/lib
can be shared between backend and frontend. - Accessible from both
test
andcelest/test
, for testing purposes.
- Accessible from your app's
-
lib
- Frontend-specific code that cannot be imported into
celest/lib
. - Only accessible from
test
, for testing purposes.
- Frontend-specific code that cannot be imported into
-
celest/functions
- Inaccessible from both your app's
lib
and fromcelest/lib
. - Only accessible from
celest/test
, for testing purposes.
- Inaccessible from both your app's
As explained, the Celest service will read the code in celest/functions, and use it to auto generate some more code inside the celest/lib directory.
For example, a celest/functions/greetings.dart
file containing this:
Future<String> sayHello(String name) async {
print('Saying hello to $name');
return 'Hello, $name!';
}
Will lead the Celest service to automatically generate a corresponding method in celest/lib/src/client/functions.dart, like so:
class CelestFunctionsGreeting {
Future<String> sayHello(String name) async {
final $response = await celest.httpClient.post(
celest.baseUri.resolve('/greeting/say-hello'),
headers: const {'Content-Type': 'application/json; charset=utf-8'},
body: jsonEncode({r'name': name}),
);
final $body = (jsonDecode($response.body) as Map<String, Object?>);
if ($response.statusCode == 200) {
return ($body['response'] as String);
}
final $error = ($body['error'] as Map<String, Object?>);
final $code = ($error['code'] as String);
final $details = ($error['details'] as Map<String, Object?>?);
switch ($code) {
case r'BadRequestException':
throw Serializers.instance.deserialize<BadRequestException>($details);
case r'InternalServerException':
throw Serializers.instance
.deserialize<InternalServerException>($details);
case _:
switch ($response.statusCode) {
case 400:
throw BadRequestException($code);
case _:
throw InternalServerException($code);
}
}
}
}
This generated sayHello()
method is the one your frontend app code in lib
actually interacts
with, not the original sayHello()
function in the functions directory (which cannot even be
imported, due to the previously mentioned access limitations).
The generated sayHello()
starts by sending an HTTP POST request to the backend,
by doing await celest.httpClient.post(url, ...)
, and will resolve the URL
with baseUri.resolve('/greeting/say-hello')
.
This is your baseUri
:
Uri get baseUri => switch (this) {
local => kIsWeb || !Platform.isAndroid
? Uri.parse('http://localhost:7778')
: Uri.parse('http://10.0.2.2:7778'),
production => Uri.parse(
'https://mobile-app-flutter-celest-xxxx-xxxxxxxxxx-xx.a.run.app'),
};
When you ran Celest with celest start
, you are using CelestEnvironment.local
,
which means http://localhost:7777
for web, and http://10.0.2.2:7777
for Android,
where 10.0.2.2
is a special alias to the host loopback interface (i.e., 127.0.0.1
on my
development machine) when using the Android Emulator.
Celest then spins up a local server on port 7777
, and the generated sayHello()
function will
send the HTTP POST request to http://...:7777/greeting/say-hello
.
If instead you ran Celest with celest deploy
, you'll be running
with CelestEnvironment.production
, and your baseUri
will be something
like https://mobile-app-flutter-celest-xxxx-xxxxxxxxxx-xx.a.run.app
.
In the backend code, as seen in file celest-0.1.1\lib\src\runtime\serve.dart
from https://pub.dev/packages/celest, Celest will:
- Decode the Json with
request.decodeJson()
- Run the original
sayHello()
function from thegreetings.dart
file withfinal response = ... handle(bodyJson)
- Encode the response with
jsonEncode(response.body)
and send it back to the frontend
Future<Response> _handler(Request request) async {
final bodyJson = await request.decodeJson();
final response = await runZoned(
() => handle(bodyJson),
zoneSpecification: ZoneSpecification(
print: (self, parent, zone, message) {
parent.print(zone, '[$name] $message');
},
),
);
return Response(
response.statusCode,
body: jsonEncode(response.body),
headers: {
contentTypeHeader: jsonContentType,
},
);
}
To recap, the original sayHello()
function you wrote
in celest/functions/greeting.dart is turned into a
generated method
inside celest/lib/src/client/functions.dart.
To access this generated sayHello()
method from your frontend app code (in lib)
you must import celest/lib/client.dart and use the global celest
object:
import 'package:celest_backend/client.dart';
var result = await celest.functions.greeting.sayHello('Celest');
Since Celest functions are cloud functions, they are always asynchronous, and can always fail, for example, because of network issues, or because the server is down, etc.
This means you must always await
the result of a Celest function, and you must always handle
the possibility of an error.
If you use a FutureBuilder
to call a Celest function, you must always handle the snapshot.error
:
FutureBuilder(
future: celest.functions.greeting.sayHello('Celest'),
builder: (_, snapshot) => switch (snapshot) {
AsyncSnapshot(:final data?) => Text(data),
AsyncSnapshot(:final error?) =>
Text('${error.runtimeType}: $error'),
_ => const CircularProgressIndicator(),
}));
In the case of a try/catch
block:
try {
var result = await celest.functions.greeting.sayHello('Celest');
print('Result: $result');
} catch (error) {
print('Error: $error');
}
In the case of a then/catchError
:
celest.functions.greeting.sayHello('Celest').then((result) {
print('Result: $result');
}).catchError((error) {
print('Error: $error');
});
Or, when using a state management solution, you'll follow its principles for handling asynchronous operations that may fail.
In this example app we are using Async Redux,
which allows you to simply define a local wrapError
in the action:
class GreetingAction extends ReduxAction<AppState> {
Future<AppState?> reduce() async {
var newGreeting = await celest.functions.greeting.sayHello('Celest');
return state.copy(greeting: newGreeting);
}
Object wrapError(Object error, StackTrace stackTrace) =>
UserException("The greeting failed.", cause: error);
}
Or a global wrapError
when creating the store:
store = Store<AppState>(
initialState: state,
wrapError: MyWrapError(), // A custom WrapError
);
class MyWrapError extends WrapError<AppState> {
Object? wrap(Object error, [StackTrace? st, ReduxAction<AppState>? action]) {
if (error is InternalServerException) {
return UserException("The greeting failed.", cause: error);
else
return null;
}}
Note: When
a wrapError
converts some error into
a UserException,
this is a special exception that will be displayed to the user in
a dialog.
However, if your Celest function directly throws a UserException
(or a subclass
of UserException
), it will also be displayed to the user in a dialog, no error wrapping needed.
[In development]