Navigation & Route Security
Before You Begin
This guide builds upon the concepts introduced in the User Authentication guide and assumes familiarity with basic routing in Flood. If you haven't completed the previous guides, you can start with the repository that ended off at the Working with Data guide by cloning https://github.com/JLogical-Apps/flood-template/tree/drop (opens in a new tab).
Routes and AppPages
In Flood, navigation is managed through the interaction of Routes and AppPages. It's important to understand the distinction between these two concepts:
- Routes define a specific URL location and its associated parameters.
- AppPages are what is rendered when a Route is active.
You register a mapping between Routes and AppPages in an AppPondComponent. The AppPage
's onBuild
method is wrapped around a HookBuilder
, allowing you to use flutter_hooks (opens in a new tab) in the body.
For more complex page behaviors, you can use an AppPageWrapper
instead of a simple AppPage
. This allows you to compose functionality and add features like redirects and parent pages.
Route Parameters
Routes can include parameters to pass additional information. These parameters can be accessed in the corresponding AppPage
. Let's explore different types of route parameters:
Query Parameters
Query parameters are included in the URL after a question mark. Let's add an only_completed
boolean query parameter to our home page:
- Update the
HomeRoute
inexample/lib/presentation/pages/home_page.dart
:
...
class HomeRoute with IsRoute<HomeRoute> {
static const onlyCompletedField = 'only_completed';
late final onlyCompletedProperty = field<bool>(name: onlyCompletedField).withFallback(() => false);
@override
PathDefinition get pathDefinition => PathDefinition.home;
@override
List<RouteProperty> get queryProperties => [onlyCompletedProperty];
@override
HomeRoute copy() {
return HomeRoute();
}
}
...
- Modify the query in the
HomePage
to filter todos based on theonly_completed
parameter:
...
class HomePage with IsAppPageWrapper<HomeRoute> {
...
@override
Widget onBuild(BuildContext context, HomeRoute route) {
final loggedInUserId = useLoggedInUserIdOrNull();
final todosModel = useQuery(getTodosQuery(loggedInUserId, route.onlyCompletedProperty.value));
...
}
Query<TodoEntity> getTodosQuery(String? loggedInUserId, bool onlyCompleted) {
Query<TodoEntity> query = Query.from<TodoEntity>()
.where(Todo.ownerField)
.isEqualTo(loggedInUserId)
.orderByAscending(CreationTimeProperty.field);
if (onlyCompleted) {
query = query.where(Todo.completedField).isEqualTo(true);
}
return query;
}
}
Now you can use the Url Bar Module to test this functionality. Try navigating to /?only_completed=true
to see only completed todos.
Path Parameters
Path parameters are part of the URL path itself. Let's create a new route for viewing individual todos:
- Create a new file
example/lib/presentation/pages/todo_details_page.dart
:
import 'package:flood/flood.dart';
import 'package:flutter/material.dart';
class TodoDetailsRoute with IsRoute<TodoDetailsRoute> {
late final todoIdProperty = field<String>(name: 'todoId').required();
@override
PathDefinition get pathDefinition => PathDefinition.string('todo').property(todoIdProperty);
@override
TodoDetailsRoute copy() => TodoDetailsRoute();
}
class TodoDetailsPage with IsAppPage<TodoDetailsRoute> {
@override
Widget onBuild(BuildContext context, TodoDetailsRoute route) {
final todoModel = useEntity<TodoEntity>(route.todoIdProperty.value);
return ModelBuilder(
model: todoModel,
builder: (TodoEntity todoEntity) {
return StyledPage(
titleText: 'Todo Details',
body: StyledList.column.withScrollbar(
children: [
StyledText.xl(todoEntity.value.nameProperty.value),
StyledCheckbox(
value: todoEntity.value.completedProperty.value,
labelText: 'Completed',
onChanged: (value) {
context.dropCoreComponent.updateEntity(
todoEntity,
(Todo todo) => todo..completedProperty.set(value),
);
},
),
],
),
);
},
);
}
}
- Update the
PagesAppPondComponent
to include the new route:
...
class PagesAppPondComponent with IsAppPondComponent {
@override
Map<Route, AppPage> get pages => {
HomeRoute(): HomePage(),
LoginRoute(): LoginPage(),
SignupRoute(): SignupPage(),
ForgotPasswordRoute(): ForgotPasswordPage(),
TodoDetailsRoute(): TodoDetailsPage(),
};
}
- Update the
HomePage
to include a link to the todo details:
...
StyledCard(
titleText: todoEntity.value.nameProperty.value,
leading: StyledCheckbox(...),
onPressed: () => context.push(TodoDetailsRoute()..todoIdProperty.set(todoEntity.id!)),
...
),
...
Now you can navigate to individual todo items, and the URL will reflect the todo's ID, e.g., /todo/123
.
Hidden Parameters
Hidden parameters are not derived from the current path but must be explicitly added when creating a route. Let's add email
and password
hidden parameters to the SignupRoute
to pre-fill the signup form with data from the login page:
-
Update the
SignupRoute
inexample/lib/presentation/pages/signup_page.dart
:example/lib/presentation/pages/signup_page.dart... class SignupRoute with IsRoute<SignupRoute> { late final initialEmailProperty = field<String>(name: 'initialEmail'); late final initialPasswordProperty = field<String>(name: 'initialPassword'); @override PathDefinition get pathDefinition => PathDefinition.string('signup'); @override List<RouteProperty> get hiddenProperties => [initialEmailProperty, initialPasswordProperty]; @override SignupRoute copy() => SignupRoute(); } ...
-
Modify the
SignupPage
to use these initial values:example/lib/presentation/pages/signup_page.dart... class SignupPage with IsAppPageWrapper<SignupRoute> { ... @override Widget onBuild(BuildContext context, SignupRoute route) { final signupPort = useMemoized(() => Port.of({ 'email': PortField.string(initialValue: route.initialEmailProperty.value) .withDisplayName('Email') .isNotBlank() .isEmail(), 'password': PortField.string(initialValue: route.initialPasswordProperty.value) .withDisplayName('Password') .isNotBlank() .isPassword(), 'confirmPassword': PortField.string() .withDisplayName('Confirm Password') .isNotBlank() .isConfirmPassword(passwordField: 'password'), })); // ... rest of the build method } }
-
Update the
LoginPage
to pass these parameters when navigating to the signup page:example/lib/presentation/pages/login_page.dart... StyledButton.strong( labelText: 'Sign Up', onPressed: () async { context.push(SignupRoute() ..initialEmailProperty.set(loginPort['email']) ..initialPasswordProperty.set(loginPort['password'])); }, ), ...
loginPort['email']
and loginPort['password']
will return the unvalidated, raw values for the email and password fields in the port.
Now, when a user clicks the "Sign Up" button on the login page, their email and password will be pre-filled in the signup form.
Parents
AppPages can have parent pages, which affects navigation and security. When you warp to a route whose AppPage has a parent, that parent page will be below the current page in the navigation stack. This creates a natural hierarchy and allows for intuitive back navigation.
It's important to note that when users navigate directly to a URL in the web version of your app (for example, https://my.todo.app/signup
), Flood will warp directly to that route. However, the parent hierarchy is still maintained. This means that even when accessing a page directly, users will still have the ability to navigate back to the parent page, preserving the intended navigation structure of your app.
Let's set LoginRoute
as the parent of SignupPage
:
- Update the
SignupPage
inexample/lib/presentation/pages/signup_page.dart
:
...
class SignupPage with IsAppPageWrapper<SignupRoute> {
@override
AppPage<SignupRoute> get appPage => AppPage<SignupRoute>().withParent((context, route) => LoginRoute());
...
}
Now, when a user navigates to the signup page, they'll see a back button that takes them to the login page. This creates a more intuitive flow for users who might want to go back to the login page instead of signing up.
Route Guards
Route guards are a powerful way to control access to pages based on the user's authentication status or other conditions. In Flood, we implement route guards using redirects on AppPageWrapper
s.
Let's create some utility functions to simplify adding common route guards:
-
Create a new file
example/lib/utils/route_utils.dart
:example/lib/utils/route_utils.dartimport 'package:flood/flood.dart'; extension RedirectAppPageExtensions<R extends Route> on AppPage<R> { AppPage<R> onlyIfLoggedIn() { return withRedirect((context, route) async { final loggedInUserId = context.authCoreComponent.loggedInUserId; if (loggedInUserId == null) { return LoginRoute().routeData; } return null; }); } AppPage<R> onlyIfNotLoggedIn() { return withRedirect((context, route) async { final loggedInUserId = context.authCoreComponent.loggedInUserId; if (loggedInUserId != null) { return HomeRoute().routeData; } return null; }); } AppPage<R> onlyIfAccountExists() { return onlyIfLoggedIn().withRedirect((context, route) async { final loggedInUserId = context.authCoreComponent.loggedInUserId; final userEntity = await Query.getByIdOrNull<UserEntity>(loggedInUserId!).get(context.dropCoreComponent); if (userEntity == null) { return LoginRoute().routeData; } return null; }); } }
Now, let's apply these route guards to our pages:
-
Update
HomePage
inexample/lib/presentation/pages/home_page.dart
:example/lib/presentation/pages/home_page.dart... class HomePage with IsAppPageWrapper<HomeRoute> { @override AppPage<HomeRoute> get appPage => AppPage<HomeRoute>().onlyIfAccountExists(); ... }
-
Update
LoginPage
inexample/lib/presentation/pages/login_page.dart
:example/lib/presentation/pages/login_page.dart... class LoginPage with IsAppPageWrapper<LoginRoute> { @override AppPage<LoginRoute> get appPage => AppPage<LoginRoute>().onlyIfNotLoggedIn(); ... }
-
Update
SignupPage
inexample/lib/presentation/pages/signup_page.dart
:example/lib/presentation/pages/login_page.dart... class SignupPage with IsAppPageWrapper<SignupRoute> { @override AppPage<SignupRoute> get appPage => AppPage<SignupRoute>() .onlyIfNotLoggedIn() .withParent((context, route) => LoginRoute()); ... }
These route guards ensure that:
- The home page is only accessible to logged-in users with existing accounts.
- The login and signup pages are only accessible to users who are not logged in.
- The signup page has the login page as its parent for intuitive navigation.
To test these out, try:
- Logging in and using the Url Bar to navigate to
/login
or/signup
. Notice that you will be taken to the home route/
instead. - Logging out and using the Url Bar to navigate to
/
. Notice that you will be taken to the/login
route instead.
Important Security Note
While route guards are excellent for controlling navigation and improving user experience, they should not be relied upon as the sole means of securing your application's data. It's crucial to implement proper security measures at the data layer, such as in your repositories and asset providers.
A motivated attacker could potentially bypass client-side route guards, so always ensure that your data is protected server-side as well. For more information on securing your data, refer to the Data Security guide.
Next Steps
Now that you have a solid understanding of navigation and route security in Flood, let's connect your application to a cloud environment. Follow the Cloud Environments guide to link your project with Firebase, which will prepare you for implementing more advanced features and data security measures.