User Onboarding

So we need to create a user onboarding page. This will be a simple page, where they can search and subscribe to podcasts they like. You can see the onboarding page after logging in.

Before We start....

Let's update our pubspec.yaml. We'll update all of our outdated dependencies, and add the latest version of Dio. We'll run flutter pub get and call it a day there!

Injectible Modules

Looking at our modules is a good place as any to start. Let's start with Share Preferences.

@module
abstract class SharedPreferencesModule {
  @preResolve
  Future<SharedPreferences> get sharedPreferences =>
      SharedPreferences.getInstance();
}

We also have some "cheater" modules for firebase dependency injection. We'll see they're actually singletons because we have a dependency on firebaseApp being finished.

@lazySingleton
class FirestoreModule {
  final FirebaseApp firebaseApp;

  FirestoreModule(this.firebaseApp);

  FirebaseFirestore get firestore => FirebaseFirestore.instance;
}
@lazySingleton
class FirebaseRemoteConfigModule {
  final FirebaseApp firebaseApp;

  FirebaseRemoteConfigModule(this.firebaseApp);

  Future<RemoteConfig> get remoteConfig {
    return RemoteConfig.instance;
  }
}

So now we'll look at a more tricky one Dio. Dio is a flutter package to manage http requests. For every request we'll want to intercept all our requests, and inject an header for API access.

@Singleton(dependsOn: [DioInterceptor])
class DioModule {
  final DioInterceptor _interceptor;

  DioModule(this._interceptor);

  Dio get dio {
    var newDio = Dio();
    newDio.options.baseUrl = "https://listen-api.listennotes.com/api/v2";
    newDio.options.connectTimeout = 5000; //5s
    newDio.options.receiveTimeout = 3000;

    newDio.interceptors.add(_interceptor.wrapper);

    return newDio;
  }
}

@Singleton(dependsOn: [ApiKeyRepository])
class DioInterceptor {
  final ApiKeyRepository apiKeyRepository;

  DioInterceptor(this.apiKeyRepository);

  InterceptorsWrapper get wrapper => InterceptorsWrapper(
        onRequest: (RequestOptions options) async {
          var apiKey = await apiKeyRepository.getApiKey();
          options.headers.addAll({"X-ListenAPI-Key": apiKey});
          return options;
        },
      );
}

We'll see why everything is labled @Singleton(dependsOn: ...) in a moment. ApiKey repository is created with an async, so we need to make sure we wait for it to be ready before we build these singletons.

The other thing to note, is the wrapper. Here's where we get the api key asynchronously and add it to the options.

In the DioModule we create our Dio with a base url that will be the same for all requests, and add the interceptor.

Models

So we have a couple of new model objects under the data directory. First up is User

@freezed
abstract class User with _$User {
  factory User(
    String email,
    List<String> subscriptionIds,
  ) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

Here we simply have a user with an email, and a list of subscribed podcasts. We are also going to be searching for podcasts from the listen notes api. So if we look at the documentation we can see what is returned. https://www.listennotes.com/api/docs/#get-api-v2-search

We'll create a model that extracts what we actually want to display from the search. We just want to get the description, title, number of episodes, and the id.

part 'search_results.freezed.dart';
part 'search_results.g.dart';

@freezed
abstract class SearchPodcastResults with _$SearchPodcastResults {
  factory SearchPodcastResults(List<PodcastResults> results) =
      _SearchPodcastResults;
  factory SearchPodcastResults.fromJson(Map<String, dynamic> json) =>
      _$SearchPodcastResultsFromJson(json);
}

@freezed
abstract class PodcastResults with _$PodcastResults {
  factory PodcastResults(
    String id,
    @JsonKey(name: "description_original") String descriptionOriginal,
    String thumbnail,
    @JsonKey(name: "title_original") String titleOriginal,
    @JsonKey(name: "total_episodes") int totalEpisodes,
  ) = _PodcastResults;
  factory PodcastResults.fromJson(Map<String, dynamic> json) =>
      _$PodcastResultsFromJson(json);
}

We take use of the json serializable as well, this handles all the json parsing for us. Dart doesn't like the use of underscores in variables names, so we use the @JsonKey annotation to tell serializable what the name of the key is.

Domian Objects

So we're now ready for our Domain Classes. A quick note first. I will be stopping the interface pattern. In dart a class is also an interface, so we can still mock just as easily, but this makes in IDE navigation easier when it's not hidden behind a repository. I'll probably be refactoring the interface out at some point, but that's a later problem.

We now have a class under domain/local/user_repository.dart. It's not really a true local repository, since it saves with firestore, and firestore is responsible for syncing, but it's local enough to not be remote either. Let's take a further look

@injectable
class UserRepository {
  final FirebaseFirestore _firestore;
  final FirebaseAuth _firebaseAuth;
  final _storePath = "/Users";

  UserRepository(FirestoreModule firestoreModule, FirebaseAuthModule authModule)
      : _firebaseAuth = authModule.firebaseAuth,
        _firestore = firestoreModule.firestore;

  Future<void> saveUser(AppUser.User user) async {
    var currentUser = _firebaseAuth.currentUser.uid;
    _firestore.collection(_storePath).doc(currentUser).set(user.toJson());
  }

  Future<AppUser.User> getUser() async {
    var currentUser = _firebaseAuth.currentUser.uid;
    var json =
        (await _firestore.collection(_storePath).doc(currentUser).get()).data();
    if (json == null || json.isEmpty) {
      return null;
    }
    return AppUser.User.fromJson(json);
  }
}

We see a get and save method. They're fairly simple, and are pretty much what you see is what you get. What's worth noting is the path to them being saved. It will be under /Users/[UserID] where UserId is the unique id set by firebase auth. We will be able to set rules that users can't get other peoples profile information in firestore's rules using this method.

Going back to the remote, let's look at the ApiKeyRepository

@Singleton()
class ApiKeyRepository {
  final RemoteConfig remoteConfig;
  final SharedPreferences sharedPreferences;
  final _apiKeyKey = "ApiKey";

  @factoryMethod
  static Future<ApiKeyRepository> createAsync(
      FirebaseRemoteConfigModule firebaseRemoteConfigModule,
      SharedPreferences sharedPreferences) async {
    var remoteConfig = await firebaseRemoteConfigModule.remoteConfig;
    return ApiKeyRepository(remoteConfig, sharedPreferences);
  }

  ApiKeyRepository(this.remoteConfig, this.sharedPreferences);

  Future<String> getApiKey() async {
    await remoteConfig.fetch();
    await remoteConfig.activateFetched();
    var apiKey = remoteConfig.getString(_apiKeyKey);
    if (apiKey != null) {
      _saveApiKey(apiKey);
      return apiKey;
    } else {
      return _getSavedApiKey();
    }
  }

  Future<void> _saveApiKey(String apiKey) async {
    sharedPreferences.setString(_apiKeyKey, apiKey);
  }

  String _getSavedApiKey() {
    return sharedPreferences.getString(_apiKeyKey);
  }
}

This repo takes in a couple of dependencies, and simply returns the api key. I'm not 100% sure on the caching of firebase remote config, so I take the safe route and if we're not able to find it, we return what we cached in shared preferences. This may be refactored out at a later date when I dive deeper into documentation, but for now this works well. Up next is the search repository.

@Singleton(dependsOn: [DioModule])
class SearchRepository {
  final Dio _dio;
  final _searchPath = "/search";

  SearchRepository(DioModule _dioModule) : _dio = _dioModule.dio;

  Future<SearchPodcastResults> searchForPodcast(String searchString) async {
    try {
      var result = await _dio.get(_searchPath, queryParameters: {
        "q": searchString,
        "type": "podcast",
      });
      if (result.isSuccessful()) {
        return SearchPodcastResults.fromJson(
            {"results": result.data["results"]});
      }
    } catch (e) {
      return SearchPodcastResults([]);
    }
    return SearchPodcastResults([]);
  }
}

Here we take in the Dio dependency we created, and actually use it. We take in the search string, and perform the http request. If we get an error, or don't' have a successful request, we return an empty podcast result. It's better to not return null when we don't have to. You can't have a null pointer exception if nothing is null. This also lines up with Dart's non-nullability features coming soon.

Data Objects

So we got ahead of ourselves a little and looked at our models, but we'll take a quick look at the new services as well.

In auth_service_impl we'll add a method called getEmail(). We'll be using this a little later


  @override
  Future<String> getEmail() async {
    var currentUser = await authRepository.getUser();
    return currentUser.email;
  }

We just get the firebase user, and get the email object. Next up is the search repository, which just passes everything along to the repository

@injectable
class SearchService {
  final SearchRepository _searchRepository;

  SearchService(this._searchRepository);

  Future<SearchPodcastResults> searchPodcast(String search) =>
      _searchRepository.searchForPodcast(search);
}

This is also true for the user service.

@injectable
class UserService {
  final UserRepository _userRepository;

  UserService(this._userRepository);

  Future<void> saveUser(User user) => _userRepository.saveUser(user);
  Future<User> getUser() => _userRepository.getUser();
}

Now we're ready for cubits.

Cubits

Before I explain the UI code, let's go over the cubits. We made a change to the auth cubit.

@freezed
abstract class AuthenticationState with _$AuthenticationState {
  factory AuthenticationState.loading() = Loading;
  factory AuthenticationState.signedOut() = SignedOut;
  factory AuthenticationState.onboarding() = Onboarding;
  factory AuthenticationState.signedIn() = SignedIn;
}

We're now adding a onbaording state, which will be displayed if we don't have a user found.

Future<void> initAuth() async {
    var isSignedIn = await authService.isSignedIn();
    if (isSignedIn) {
      _user = await userService.getUser();
      if (_user == null) {
        emit(AuthenticationState.onboarding());
      } else {
        emit(AuthenticationState.signedIn());
      }
    } else {
      emit(AuthenticationState.signedOut());
    }
  }

  Future<void> _signedIn() async {
    _user = await userService.getUser();
    if (_user == null) {
      emit(AuthenticationState.onboarding());
    } else {
      emit(AuthenticationState.signedIn());
    }
  }

both _signIn() and initAuth() check the user service if one can be found. If it can be, then we go home, otherwise we go to onboarding. We'll also change the saveUser method.

Future<void> saveNewUser(List<String> podcastIds) async {
    var email = await authService.getEmail();
    _user = User(email, podcastIds);
    await userService.saveUser(_user);
    _signedIn();
  }

This will be used by the onboarding page to save the new user. It will pass the list of podcast ids, and create a new user. While we're talking about adding this new onboarding state, in the authentication_navigation_delegate.dart we'll change the getPages method to add onboarding;

 List<Page<dynamic>> getPages() {
    List<Page<dynamic>> pages = [];
    _currentState.when(
      loading: () => pages.add(SplashPage()),
      signedOut: () => pages.add(AuthenticationPage()),
      signedIn: () => pages.add(HomePage()),
      onboarding: () => pages.add(OnboardingPage()),
    );
    return pages;
  }

Now we can look at the new cubit, OnboardingSearchCubit. This will be responsible for handling the search of podcasts, as well as store the selected podcasts.

@freezed
abstract class OnboardingSearchState with _$OnboardingSearchState {
  factory OnboardingSearchState.loading() = _Loading;
  factory OnboardingSearchState.empty() = _Empty;
  factory OnboardingSearchState.loaded(
      SearchPodcastResults results, List<String> selectedIds) = _Loaded;
}

We have 3 states, loading, empty, and loaded. Empty is when we searched, but have no results. This way we can display a tailored message in the UI.

@Singleton(dependsOn: [SearchRepository])
class OnboardingSearchCubit extends Cubit<OnboardingSearchState> {
  final SearchService _searchService;
  List<String> _selectedIds = [];
  SearchPodcastResults _results = SearchPodcastResults([]);

  OnboardingSearchCubit(this._searchService)
      : super(OnboardingSearchState.empty());

  Future<void> search(String searchString) async {
    emit(OnboardingSearchState.loading());
    _results = await _searchService.searchPodcast(searchString);
    if (_results.results.isEmpty) {
      emit(OnboardingSearchState.empty());
    } else {
      emit(OnboardingSearchState.loaded(_results, _selectedIds));
    }
  }

  void toggleId(String id) {
    emit(OnboardingSearchState.loading());
    if (_selectedIds.contains(id)) {
      _selectedIds.remove(id);
    } else {
      _selectedIds.add(id);
    }
    emit(OnboardingSearchState.loaded(_results, _selectedIds));
  }
}

Once again, we have a dependency on the SearchRepository singleton. So we need to pass that along here.

The search method passes on the search string to the service, and then checks for the empty. If it is empty, it emits empty, otherwise it emits the loaded.

The toggleId method checks if we already have saved the podcast id. If the Id is already saved, it remove it, otherwise it will add it.

UI

The first UI change we'll make is actually to main.dart.

Future<void> _initState() async {
    await configureDependencies();
    //we have some dependencies we need to await for as well
    await GetIt.instance.allReady();
    setState(() {
      initFinished = true;
    });
  }

Our init state method needs to wait for our async dependences to be ready. So we keep displaying the splash screen until we are. Now onto the meat and potatoes of the UI, the new onboarding page

class OnboardingPage extends PlatformPage {
  OnboardingPage() : super(OnboardingWidget(), "/onboarding");
}

class OnboardingWidget extends StatelessWidget {
  Widget _buildResults(OnboardingSearchCubit cubit,
      SearchPodcastResults results, List<String> selectedIds) {
    return ListView.separated(
      separatorBuilder: (_, index) => Divider(),
      itemCount: results.results.length,
      itemBuilder: (BuildContext context, int index) {
        var podcast = results.results[index];
        return Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            Container(
              constraints: BoxConstraints(maxHeight: 100, maxWidth: 100),
              child: Image.network(podcast.thumbnail),
            ),
            Container(
              constraints: BoxConstraints(maxWidth: 200),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      Container(
                        constraints: BoxConstraints(maxWidth: 150),
                        child: Column(
                          mainAxisSize: MainAxisSize.min,
                          children: [
                            Text(
                              podcast.titleOriginal,
                              style: Theme.of(context)
                                  .textTheme
                                  .headline2
                                  .copyWith(fontSize: 18),
                            ),
                          ],
                        ),
                      ),
                      IconButton(
                        icon: Icon(
                          selectedIds.contains(podcast.id)
                              ? Icons.favorite
                              : Icons.favorite_border,
                          color: Colors.white,
                        ),
                        onPressed: () => cubit.toggleId(podcast.id),
                      ),
                    ],
                  ),
                  Text(
                    podcast.descriptionOriginal,
                    maxLines: 3,
                    style: Theme.of(context)
                        .textTheme
                        .caption
                        .copyWith(fontSize: 16),
                  ),
                ],
              ),
            ),
          ],
        );
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    var cubit = GetIt.instance.get<OnboardingSearchCubit>();
    var authCubit = BlocProvider.of<AuthenticationCubit>(context);
    return PlatformScaffold(
      child: Padding(
        padding: const EdgeInsets.all(8.0),
        child: BlocBuilder(
          cubit: cubit,
          builder: (BuildContext context, OnboardingSearchState state) {
            return Flex(
              direction: Axis.vertical,
              children: [
                Align(
                  alignment: Alignment.centerRight,
                  child: FlatButton(
                    child: Text("Finish"),
                    onPressed: () {
                      state.maybeWhen(
                          orElse: () {},
                          loaded: (_, selectedIds) =>
                              authCubit.saveNewUser(selectedIds));
                    },
                  ),
                ),
                Align(
                  alignment: Alignment.center,
                  child: Column(
                    mainAxisSize: MainAxisSize.min,
                    crossAxisAlignment: CrossAxisAlignment.center,
                    children: [
                      FaIcon(
                        FontAwesomeIcons.podcast,
                        color: Colors.white,
                        size: 100,
                      ),
                      Text("What do you like to listen to?"),
                    ],
                  ),
                ),
                TextField(
                  decoration: InputDecoration(
                    labelText: "Search",
                    suffixIcon: Icon(Icons.search),
                  ),
                  onChanged: (newString) => cubit.search(newString),
                ),
                Expanded(
                  flex: 3,
                  child: Builder(
                    builder: (BuildContext context) {
                      return state.when(
                        loading: () => LoadingWidget(),
                        empty: () => Center(
                          child: Text("Nothing to show!"),
                        ),
                        loaded: (results, ids) =>
                            _buildResults(cubit, results, ids),
                      );
                    },
                  ),
                )
              ],
            );
          },
        ),
      ),
    );
  }
}

The base of the screen is wrapped in a flex. This will change the size of the components based on screen size automagically. When we add text to the search bar, we pass it to the cubit. The results are then displayed under _buildResults if there are any.

From there it's a simple ListView.divider that displays the results.

Testing

Of course all the cubits, services, and repositories are unit tested. If you want to view them, take a look at the source code at https://github.com/cadaniel/FCast

Next up...

In the next post we'll explore how we're going to set up CICD using BitRise. I know I started off with codemagic, but I actually like the periodic build feature of BitRise.

Subscribe

If you're new to the series, make sure you sign up to get email updates!

Subscribe

* indicates required
Casey Daniel

Casey Daniel

Canada
Tracy Pyett

Tracy Pyett