Creating the Home Screen

So let's start off with building the home screen. Below is what you'll see when we're done.

Getting the podcasts

Let's start by fetching the data we need from the listen notes API. The docs can be found at https://www.listennotes.com/api/docs/?#get-api-v2-curated_podcasts-id

We can see a sample response on the documentation page. I'll pull out the important details

{
	"curated_lists": {
    	"title": String,
        "description": String, 
        "podcasts":[
        	{
            	"id": String,
                "image": String,
                "title: String,
            },
            ...
        ]
    }
}

With this we can build out our model objects. First up is Curated Podcasts object

@freezed
abstract class CuratedPodcasts with _$CuratedPodcasts {
  factory CuratedPodcasts(
      @JsonKey(name: "curated_lists")
          List<CuratedPodcast> curatedPodcasts) = _CuratedPodcasts;
  factory CuratedPodcasts.fromJson(Map<String, dynamic> json) =>
      _$CuratedPodcastsFromJson(json);
}

Now we have our Curated Podcast

@freezed
abstract class CuratedPodcast with _$CuratedPodcast {
  factory CuratedPodcast(
    String title,
    String description,
    List<PodcastCurated> podcasts,
  ) = _CuratedPodcast;

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

Now we have an awkward object called PodcastCurated, this has less information than a podcast object, but naming things is hard.

@freezed
abstract class PodcastCurated with _$PodcastCurated {
  factory PodcastCurated(
          String id, String image, String title, @nullable String description) =
      _PodcastCurated;
  factory PodcastCurated.fromJson(Map<String, dynamic> json) =>
      _$PodcastCuratedFromJson(json);
}

All of these objects are created via Freezed as usual, and this will generate our to/from JSON.

Repository

Now that we have our objects together, let's build the repository layer.

@injectable
class CuratedPodcastRepository {
  final Dio _dio;
  final String _uriPath = "/curated_podcasts";

  CuratedPodcastRepository(DioModule dioModule) : _dio = dioModule.dio;

  Future<CuratedPodcasts> getCuratedPodcasts() async {
    try {
      var response = await _dio.get(_uriPath);
      if (!response.isSuccessful()) {
        return CuratedPodcasts([]);
      }
      var data = response.data as Map<String, dynamic>;
      return CuratedPodcasts.fromJson(data);
    } catch (e) {
      print(e);
    }
    return CuratedPodcasts([]);
  }
}

This might look a little familiar, that's because it should be. It's very similar to our search repository. When called, we try to get the information from the API, and parse the response. If we can't or don't get a successful reply, we return an empty response.

Services

The service layer is a super simple one. When called we pass it to the repository. No additional parsing required.

@injectable
class CuratedPodcastService {
  final CuratedPodcastRepository _curatedPodcastRepository;

  CuratedPodcastService(this._curatedPodcastRepository);

  Future<CuratedPodcasts> getCuratedPodcasts() =>
      _curatedPodcastRepository.getCuratedPodcasts();
}

Preping the UI, bottom navigation

So our design calls for a bottom navigation bar. Right now we only have one page, home. But we need a minimum of two items for bottom navigation. So we'll create the home page twice, and replace it as well as add others later on in the series.

This work needs to be done in the home.dart. Navigation wise, this will be our new base. Let's take a look at our changes.

class HomePage extends PlatformPage {
  HomePage() : super(Home(), "/home");
}

class Home extends StatefulWidget {
  @override
  _HomeState createState() => _HomeState();
}

class _HomeState extends State<Home> {
  int _currentIndex = 0;

  List<BottomNavigationBarItem> _getNavigationItems = [
    BottomNavigationBarItem(icon: Icon(Icons.home), label: "Home"),
    BottomNavigationBarItem(icon: Icon(Icons.home), label: "Home 2"),
  ];

  List<Widget> _pages = [
    PodcastHomePage(),
    PodcastHomePage(),
  ];

  Widget tabBuilder(BuildContext context, int index) {
    return _pages[index];
  }

  @override
  Widget build(BuildContext context) {
    return PlatformScaffold(
      child: _pages[_currentIndex],
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentIndex,
        items: _getNavigationItems,
      ),
      tabBuilder: tabBuilder,
      cupertinoTabBar: CupertinoTabBar(
        backgroundColor: Theme.of(context).bottomAppBarColor,
        activeColor: Colors.white,
        inactiveColor: Colors.grey.shade400,
        items: _getNavigationItems,
      ),
    );
  }
}

As noted before, we have the home page twice. This is done to allow us to get the tab bar built, and we'll replace them later on. We sill have our PlatformPage at the top, and Home is now a stateful widget. Our tabs and pages are member objects, and stored as lists. Now we notice some changes with out PlatformScaffold, we added arguments to support tab bars at the bottom. Let's go take a look!

class PlatformScaffold extends StatelessWidget {
  final Widget child;
  final CupertinoNavigationBar cupertinoNavigationBar;
  final BottomNavigationBar bottomNavigationBar;
  final CupertinoTabBar cupertinoTabBar;
  final AppBar materialAppBar;
  final IndexedWidgetBuilder tabBuilder;

  const PlatformScaffold({
    Key key,
    this.cupertinoNavigationBar,
    this.materialAppBar,
    @required this.child,
    this.bottomNavigationBar,
    this.cupertinoTabBar,
    this.tabBuilder,
  })  : assert(child != null),
        super(key: key);

  @override
  Widget build(BuildContext context) {
    if (Platform.isIOS && cupertinoTabBar == null) {
      return Material(
        child: SafeArea(
          child: CupertinoPageScaffold(
            navigationBar: cupertinoNavigationBar,
            child: child,
          ),
        ),
      );
    } else if (Platform.isIOS && cupertinoTabBar != null) {
      return Material(
        child: SafeArea(
          child: CupertinoTabScaffold(
            tabBar: cupertinoTabBar,
            tabBuilder: tabBuilder,
          ),
        ),
      );
    } else {
      return SafeArea(
        child: Scaffold(
          appBar: materialAppBar,
          body: child,
          bottomNavigationBar: bottomNavigationBar,
        ),
      );
    }
  }
}

So let's talk about that build method. For material widgets, bottom app bar is supported. For Cupertino, we have some other things to look at. CupertinoTabScaffold and CupertinoScaffold are different here. So if we don't pass any tab objects, we use the scaffold, otherwise we use the tab scaffold.

New Additions

So before we build the page, we need to quickly talk about what we're adding to the project in our pubspec.yaml.

carousel_slider: ^2.3.1

We'll see this used in our next section, but it will allow us to display the horizontal carousel.

Curated Podcasts Page

Let's build the curated podcast page now. We'll start with the cubit.

@freezed
abstract class HomeState with _$HomeState {
  factory HomeState.loading() = _Loading;
  factory HomeState.loaded(CuratedPodcasts curatedPodcasts) = _Loaded;
}
class HomeCubit extends Cubit<HomeState> {
  final CuratedPodcastService _curatedPodcastService;
  CuratedPodcasts _podcasts;

  HomeCubit(this._curatedPodcastService) : super(HomeState.loading()) {
    _getPodcasts();
  }

  Future<void> refresh() async {
    emit(HomeState.loading());
    _getPodcasts();
  }

  Future<void> _getPodcasts() async {
    _podcasts = await _curatedPodcastService.getCuratedPodcasts();
    _emitState();
  }

  void _emitState() {
    emit(HomeState.loaded(_podcasts));
  }
}

It's a simple state, and cubit. When the cubit is created, we load the curated podcasts from the service, and emit the state. We also have a refresh method that emits a loading state, and then calls the same fetch podcasts function. Now let's look at our page.

class PodcastHomePage extends StatelessWidget {
  final _homeCubit = GetIt.instance.get<HomeCubit>();

  Widget _buildLoading() {
    return Container(
      child: Center(
        child: LoadingWidget(),
      ),
    );
  }

  Widget _buildLoaded(CuratedPodcasts podcasts) {
    return RefreshIndicator(
      onRefresh: () => _homeCubit.refresh(),
      child: ListView.builder(
        shrinkWrap: true,
        itemCount: podcasts.curatedPodcasts.length,
        itemBuilder: (BuildContext context, int index) {
          var curatedPodcasts = podcasts.curatedPodcasts[index];
          return Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Padding(
                padding: const EdgeInsets.only(left: 16.0),
                child: Text(
                  curatedPodcasts.title,
                  textAlign: TextAlign.center,
                  style: Theme.of(context).textTheme.headline3,
                ),
              ),
              CarouselSlider.builder(
                itemCount: curatedPodcasts.podcasts.length,
                options: CarouselOptions(
                  autoPlay: false,
                  aspectRatio: 2.0,
                  enlargeCenterPage: true,
                ),
                itemBuilder: (_, int index) {
                  return PodcastCard(podcast: curatedPodcasts.podcasts[index]);
                },
              ),
            ],
          );
        },
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return BlocBuilder(
      cubit: _homeCubit,
      builder: (BuildContext context, HomeState state) {
        return state.when(
          loading: _buildLoading,
          loaded: _buildLoaded,
        );
      },
    );
  }
}

Here we display two states, given by the cubit. A simple loading screen, and then the actual podcasts. Each list of curated podcasts is placed in a list view. To display these cards, let's look at PodcastCard

class PodcastCard extends StatelessWidget {
  final PodcastCurated podcast;

  const PodcastCard({Key key, this.podcast}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Card(
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(15.0),
      ),
      child: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Flex(
          direction: Axis.horizontal,
          children: [
            Flexible(
              child: Image.network(podcast.image),
            ),
            SizedBox(width: 8),
            Flexible(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.start,
                children: [
                  Text(
                    podcast.title,
                    maxLines: 3,
                  ),
                  Text(podcast.description ?? ""),
                ],
              ),
            )
          ],
        ),
      ),
    );
  }
}

This widget is mostly style. It displayes the image, title, and optional description in the card. Simple enough!

Testing

As always, tests can be found in the source code found at https://github.com/cadaniel/FCast

I won't bore you with the details, since nothing new will be covered here.

Like what you see? Subscribe!

If you like the content I'm creating here, and want to follow along, make sure you hit subscribe! Want to support this content? There's a paid teir as well. You'll get early access to posts and access to the beta via TestFlight and the Play Store when it's ready for testing.

Casey Daniel

Casey Daniel

Canada
Tracy Pyett

Tracy Pyett