skip to Main Content

I have a Flutter page that makes use of 2 data sources: one from API (Internet) and one from Shared Preferences. The API source has no problem, as I used FutureBuilder in the build() method. For the Shared Preferences, I have no idea how to apply another Future Builder (or should I add one more?). Here are the codes (I tried to simplify them):

Future<List<City>> fetchCities(http.Client client) async {
  final response = await client
      .get(Uri.parse('https://example.com/api/'));
  return compute(parseCities, response.body);
}

List<City> parseCities(String responseBody) {
  final parsed = jsonDecode(responseBody).cast<Map<String, dynamic>>();
  return parsed.map<City>((json) => City.fromJson(json)).toList();
}

class CityScreen extends StatelessWidget {
  static const routeName = '/city';

  const CityScreen({super.key, required this.title});
  final String title;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text(title),
        ),
        body: FutureBuilder<List<City>>(
          future: fetchCities(http.Client()),
          builder: (context, snapshot) {
            if (snapshot.hasError) {
              return Center(
                child: Text(snapshot.error.toString()),
              );
            } else if (snapshot.hasData) {
              return CityList(cities: snapshot.data!);
            } else {
              return const Center(
                child: CircularProgressIndicator(),
              );
            }
          },
        )
    );
  }
}

class CityList extends StatefulWidget {
  const CityList({super.key, required this.cities});

  final List<City> cities;

  @override
  State<CityList> createState() => _CityListState();
}

class _CityListState extends State<CityList> {
  List<String> completedMissionIDs = [];
  @override
  void initState() {
    super.initState();
    Player.loadMissionStatus().then((List<String> result) {
      setState(() {
        completedMissionIDs = result;
        if (kDebugMode) {
          print(completedMissionIDs);
        }
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      padding: const EdgeInsets.all(16.0),
      itemCount: widget.cities.length * 2,
      itemBuilder: (context, i) {
        if (i.isOdd) return const Divider();
        final index = i ~/ 2;

        double completedPercent = _calculateCompletionPercent(widget.cities[index].missionIDs, completedMissionIDs);

        return ListTile(
          leading: const Icon(Glyphicon.geo, color: Colors.blue),
          title: Text(widget.cities[index].cityName),
          trailing: Text('$completedPercent%'),
          onTap: () {
            Navigator.of(context).push(
                MaterialPageRoute(
                  builder: (context) => MissionScreen(title: '${widget.cities[index].cityName} Missions', cityId: widget.cities[index].id),
                )
            );
          },
        );
      },
    );
  }

  double _calculateCompletionPercent<T>(List<T> cityMissionList, List<T> completedList) {

    if(cityMissionList.isEmpty) {
      return 0;
    }
    int completedCount = 0;
    for (var element in completedList) {
      if(cityMissionList.contains(element)) {
        completedCount++;
      }
    }
    if (kDebugMode) {
      print('Completed: $completedCount, Total: ${cityMissionList.length}');
    }
    return completedCount / cityMissionList.length;
  }
}

The problem is, the build function in the _CityListState loads faster than the Player.loadMissionStatus() method in the initState, which loads a List<int> from shared preferences.

The shared preferences are loaded in the midway of the ListTiles are generated, making the result of completedPercent inaccurate. How can I ask the ListTile to be built after the completedPercent has been built?

Thanks.

2

Answers


  1. First of all I would separate the data layer from the presentation. Bloc would be one example.

    To combine 2 Futures you could do something like

    final multiApiResult = await Future.wait([
      sharedPrefs.get(),
      Player.loadMissionStatus()
    ])
    
    Login or Signup to reply.
  2. I would start by making CityList a StatelessWidget that accepts completedMissionIDs as a constructor parameter.

    Your CityScreen widget can call both APIs and combine the results into a single Future. Pass the combined Future to your FutureBuilder. That way you can render the CityList once all of the data has arrived from both APIs.

    I put together a demo below:

    import 'package:flutter/material.dart';
    
    void main() {
      runApp(const MaterialApp(
        home: CityScreen(title: 'City Screen'),
      ));
    }
    
    class CombinedResult {
      final List<City> cities;
      final List<int> status;
      const CombinedResult({
        required this.cities,
        required this.status,
      });
    }
    
    class City {
      final String cityName;
      final List<int> missionIDs;
      const City(this.cityName, this.missionIDs);
    }
    
    class Player {
      static Future<List<int>> loadMissionStatus() async {
        await Future.delayed(const Duration(seconds: 1));
        return [0, 3];
      }
    }
    
    Future<List<City>> fetchCities() async {
      await Future.delayed(const Duration(seconds: 2));
      return const [
        City('Chicago', [1, 2, 3, 4]),
        City('Helsinki', [1, 2, 3, 4]),
        City('Kathmandu', [0, 4]),
        City('Seoul', [1, 2, 3]),
      ];
    }
    
    class CityScreen extends StatefulWidget {
      const CityScreen({super.key, required this.title});
      final String title;
    
      @override
      State<CityScreen> createState() => _CityScreenState();
    }
    
    class _CityScreenState extends State<CityScreen> {
      late Future<CombinedResult> _future;
    
      @override
      void initState() {
        super.initState();
        _future = _fetchData();
      }
    
      Future<CombinedResult> _fetchData() async {
        final cities = await fetchCities();
        final status = await Player.loadMissionStatus();
        return CombinedResult(cities: cities, status: status);
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text(widget.title),
          ),
          body: FutureBuilder<CombinedResult>(
            future: _future,
            builder: (context, snapshot) {
              if (snapshot.hasError) {
                return Center(
                  child: Text(snapshot.error.toString()),
                );
              } else if (snapshot.hasData) {
                return CityList(
                  cities: snapshot.data!.cities,
                  completedMissionIDs: snapshot.data!.status,
                );
              } else {
                return const Center(
                  child: CircularProgressIndicator(),
                );
              }
            },
          ),
        );
      }
    }
    
    class CityList extends StatelessWidget {
      const CityList({
        super.key,
        required this.cities,
        required this.completedMissionIDs,
      });
    
      final List<City> cities;
      final List<int> completedMissionIDs;
    
      @override
      Widget build(BuildContext context) {
        return ListView.separated(
          padding: const EdgeInsets.all(16.0),
          itemCount: cities.length,
          separatorBuilder: (context, i) => const Divider(),
          itemBuilder: (context, i) => ListTile(
            leading: const Icon(Icons.location_city, color: Colors.blue),
            title: Text(cities[i].cityName),
            trailing: Text(
                '${_calculateCompletionPercent(cities[i].missionIDs, completedMissionIDs)}%'),
          ),
        );
      }
    
      double _calculateCompletionPercent<T>(
              List<T> cityMissionList, List<T> completedList) =>
          completedList.where(cityMissionList.contains).length /
          cityMissionList.length;
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search