feature: querying and displaying upcoming movies
parent
4739a2996d
commit
91e18b279d
|
@ -15,6 +15,8 @@
|
|||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<!-- Required to fetch data from the internet. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class RateLimitStatus {
|
||||
int consecutiveCount = 0;
|
||||
Duration timeout = const Duration(seconds: 1);
|
||||
}
|
||||
|
||||
class ApiManager {
|
||||
String baseUrl;
|
||||
Future<void> ongoingRequest = Future.value();
|
||||
int consecutiveRateLimitExceeded = 0;
|
||||
Duration rateLimitTimeout = Duration.zero;
|
||||
DateTime? lastRequest;
|
||||
|
||||
ApiManager(this.baseUrl);
|
||||
|
||||
Future<http.Response> get(String path) async {
|
||||
Future<void> waitingForRequest = ongoingRequest;
|
||||
Completer completer = Completer();
|
||||
try {
|
||||
ongoingRequest = completer.future;
|
||||
await waitingForRequest;
|
||||
|
||||
DateTime? lastRequestLocal = lastRequest;
|
||||
if (consecutiveRateLimitExceeded > 0 &&
|
||||
lastRequestLocal != null &&
|
||||
DateTime.now().isBefore(lastRequestLocal.add(rateLimitTimeout))) {
|
||||
throw Exception("Too many requests");
|
||||
}
|
||||
|
||||
http.Response response = await http.get(Uri.parse(baseUrl + path));
|
||||
lastRequest = DateTime.now();
|
||||
if (response.statusCode == 429) {
|
||||
consecutiveRateLimitExceeded++;
|
||||
if (consecutiveRateLimitExceeded == 1) {
|
||||
rateLimitTimeout = const Duration(seconds: 1);
|
||||
} else {
|
||||
rateLimitTimeout *= 2;
|
||||
}
|
||||
String? retryAfter = response.headers["Retry-After"];
|
||||
if (retryAfter != null) {
|
||||
int? retryAfterSeconds = int.tryParse(retryAfter);
|
||||
if (retryAfterSeconds != null) {
|
||||
Duration retryAfterDuration = Duration(seconds: retryAfterSeconds);
|
||||
if (retryAfterDuration > rateLimitTimeout) {
|
||||
rateLimitTimeout = retryAfterDuration;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw Exception("Too many requests");
|
||||
} else {
|
||||
consecutiveRateLimitExceeded = 0;
|
||||
}
|
||||
return response;
|
||||
} finally {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import 'package:release_schedule/model/movie.dart';
|
||||
|
||||
abstract class MovieApi<CustomMovieData extends MovieData> {
|
||||
Future<List<CustomMovieData>> getUpcomingMovies([int count]);
|
||||
Future<List<CustomMovieData>> searchForMovies(String searchTerm);
|
||||
Future<void> addMovieDetails(List<CustomMovieData> movies);
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart';
|
||||
import 'package:release_schedule/api/api_manager.dart';
|
||||
import 'package:release_schedule/api/movie_api.dart';
|
||||
import 'package:release_schedule/model/movie.dart';
|
||||
|
||||
class WikidataMovieData extends MovieData {
|
||||
int entityId;
|
||||
WikidataMovieData(String title, DateTime releaseDate, this.entityId)
|
||||
: super(title, releaseDate);
|
||||
|
||||
@override
|
||||
bool same(MovieData other) {
|
||||
return other is WikidataMovieData && entityId == other.entityId;
|
||||
}
|
||||
}
|
||||
|
||||
String createUpcomingMovieQuery(int limit) {
|
||||
return """
|
||||
SELECT
|
||||
?movie
|
||||
?movieLabel
|
||||
(MIN(?releaseDate) as ?minReleaseDate)
|
||||
WHERE {
|
||||
?movie wdt:P31 wd:Q11424; # Q11424 is the item for "film"
|
||||
wdt:P577 ?releaseDate; # P577 is the "publication date" property
|
||||
wdt:P1476 ?title.
|
||||
FILTER (xsd:date(?releaseDate) >= xsd:date(NOW()))
|
||||
|
||||
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
|
||||
}
|
||||
GROUP BY ?movie ?movieLabel
|
||||
ORDER BY ?minReleaseDate
|
||||
LIMIT $limit""";
|
||||
}
|
||||
|
||||
class WikidataMovieApi implements MovieApi<WikidataMovieData> {
|
||||
ApiManager searchApi = ApiManager("https://www.wikidata.org/w/api.php");
|
||||
ApiManager queryApi =
|
||||
ApiManager("https://query.wikidata.org/sparql?format=json");
|
||||
|
||||
@override
|
||||
Future<void> addMovieDetails(List<WikidataMovieData> movies) {
|
||||
// TODO: implement addMovieDetails
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<WikidataMovieData>> getUpcomingMovies([int count = 100]) async {
|
||||
Response response = await queryApi
|
||||
.get("&query=${Uri.encodeComponent(createUpcomingMovieQuery(count))}");
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
"The Wikidata request for upcoming movies failed with status ${response.statusCode} ${response.reasonPhrase}");
|
||||
}
|
||||
Map<String, dynamic> result = jsonDecode(response.body);
|
||||
List<dynamic> entries = result["results"]["bindings"];
|
||||
List<WikidataMovieData> movies = [];
|
||||
for (Map<String, dynamic> entry in entries) {
|
||||
String identifier =
|
||||
RegExp(r"Q\d+$").firstMatch(entry["movie"]["value"])![0]!;
|
||||
movies.add(WikidataMovieData(
|
||||
entry["movieLabel"]["value"] as String,
|
||||
DateTime.parse(entry["minReleaseDate"]["value"] as String),
|
||||
int.parse(identifier.substring(1))));
|
||||
}
|
||||
return movies;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<WikidataMovieData>> searchForMovies(String searchTerm) {
|
||||
// TODO: implement searchForMovies
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
122
lib/main.dart
122
lib/main.dart
|
@ -1,125 +1,55 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:release_schedule/api/movie_api.dart';
|
||||
import 'package:release_schedule/api/wikidata_movie_api.dart';
|
||||
import 'package:release_schedule/model/movie_manager.dart';
|
||||
import 'package:release_schedule/view/movie_list.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
movieManager.loadUpcomingMovies();
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
// This widget is the root of your application.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flutter Demo',
|
||||
title: 'Movie Schedule',
|
||||
themeMode: ThemeMode.dark,
|
||||
darkTheme: ThemeData.dark(useMaterial3: true),
|
||||
theme: ThemeData(
|
||||
// This is the theme of your application.
|
||||
//
|
||||
// TRY THIS: Try running your application with "flutter run". You'll see
|
||||
// the application has a blue toolbar. Then, without quitting the app,
|
||||
// try changing the seedColor in the colorScheme below to Colors.green
|
||||
// and then invoke "hot reload" (save your changes or press the "hot
|
||||
// reload" button in a Flutter-supported IDE, or press "r" if you used
|
||||
// the command line to start the app).
|
||||
//
|
||||
// Notice that the counter didn't reset back to zero; the application
|
||||
// state is not lost during the reload. To reset the state, use hot
|
||||
// restart instead.
|
||||
//
|
||||
// This works for code too, not just values: Most code changes can be
|
||||
// tested with just a hot reload.
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const MyHomePage(title: 'Flutter Demo Home Page'),
|
||||
home: HomePage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({super.key, required this.title});
|
||||
class HomePage extends StatelessWidget {
|
||||
final MovieApi api = WikidataMovieApi();
|
||||
|
||||
// This widget is the home page of your application. It is stateful, meaning
|
||||
// that it has a State object (defined below) that contains fields that affect
|
||||
// how it looks.
|
||||
|
||||
// This class is the configuration for the state. It holds the values (in this
|
||||
// case the title) provided by the parent (in this case the App widget) and
|
||||
// used by the build method of the State. Fields in a Widget subclass are
|
||||
// always marked "final".
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<MyHomePage> createState() => _MyHomePageState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
int _counter = 0;
|
||||
|
||||
void _incrementCounter() {
|
||||
setState(() {
|
||||
// This call to setState tells the Flutter framework that something has
|
||||
// changed in this State, which causes it to rerun the build method below
|
||||
// so that the display can reflect the updated values. If we changed
|
||||
// _counter without calling setState(), then the build method would not be
|
||||
// called again, and so nothing would appear to happen.
|
||||
_counter++;
|
||||
});
|
||||
}
|
||||
HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// This method is rerun every time setState is called, for instance as done
|
||||
// by the _incrementCounter method above.
|
||||
//
|
||||
// The Flutter framework has been optimized to make rerunning build methods
|
||||
// fast, so that you can just rebuild anything that needs updating rather
|
||||
// than having to individually change instances of widgets.
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// TRY THIS: Try changing the color here to a specific color (to
|
||||
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
|
||||
// change color while the other colors stay the same.
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
// Here we take the value from the MyHomePage object that was created by
|
||||
// the App.build method, and use it to set our appbar title.
|
||||
title: Text(widget.title),
|
||||
appBar: AppBar(title: const Text("Release Schedule")),
|
||||
body: AnimatedBuilder(
|
||||
animation: movieManager,
|
||||
// future: api.getUpcomingMovies(),
|
||||
builder: (context, widget) {
|
||||
return MovieList(movieManager.movies);
|
||||
// var data = snapshot.data;
|
||||
// if (snapshot.hasData && data != null) {
|
||||
// return MovieList(data);
|
||||
// } else if (snapshot.hasError) {
|
||||
// return ErrorWidget(snapshot.error ?? "Something went wrong");
|
||||
// }
|
||||
// return const Center(child: CircularProgressIndicator());
|
||||
},
|
||||
),
|
||||
body: Center(
|
||||
// Center is a layout widget. It takes a single child and positions it
|
||||
// in the middle of the parent.
|
||||
child: Column(
|
||||
// Column is also a layout widget. It takes a list of children and
|
||||
// arranges them vertically. By default, it sizes itself to fit its
|
||||
// children horizontally, and tries to be as tall as its parent.
|
||||
//
|
||||
// Column has various properties to control how it sizes itself and
|
||||
// how it positions its children. Here we use mainAxisAlignment to
|
||||
// center the children vertically; the main axis here is the vertical
|
||||
// axis because Columns are vertical (the cross axis would be
|
||||
// horizontal).
|
||||
//
|
||||
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
|
||||
// action in the IDE, or press "p" in the console), to see the
|
||||
// wireframe for each widget.
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'You have pushed the button this many times:',
|
||||
),
|
||||
Text(
|
||||
'$_counter',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _incrementCounter,
|
||||
tooltip: 'Increment',
|
||||
child: const Icon(Icons.add),
|
||||
), // This trailing comma makes auto-formatting nicer for build methods.
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class Review {
|
||||
String score;
|
||||
String by;
|
||||
DateTime asOf;
|
||||
int count;
|
||||
|
||||
Review(this.score, this.by, this.asOf, this.count);
|
||||
}
|
||||
|
||||
typedef ReleaseDateInCountry = (String country, DateTime date);
|
||||
typedef TitleInCountry = (String country, String title);
|
||||
|
||||
class MovieData extends ChangeNotifier {
|
||||
final String title;
|
||||
final DateTime releaseDate;
|
||||
bool _hasDetails = false;
|
||||
List<ReleaseDateInCountry> _releaseDates = [];
|
||||
List<String> _genres = [];
|
||||
List<TitleInCountry> _titles = [];
|
||||
List<Review> _reviews = [];
|
||||
|
||||
List<ReleaseDateInCountry>? get releaseDates {
|
||||
return _releaseDates;
|
||||
}
|
||||
|
||||
List<String>? get genres {
|
||||
return _genres;
|
||||
}
|
||||
|
||||
List<TitleInCountry>? get titles {
|
||||
return _titles;
|
||||
}
|
||||
|
||||
List<Review>? get reviews {
|
||||
return _reviews;
|
||||
}
|
||||
|
||||
bool get hasDetails {
|
||||
return _hasDetails;
|
||||
}
|
||||
|
||||
void setDetails(
|
||||
{List<ReleaseDateInCountry>? releaseDates,
|
||||
List<String>? genres,
|
||||
List<TitleInCountry>? titles,
|
||||
List<Review>? reviews}) {
|
||||
if (releaseDates != null) {
|
||||
_releaseDates = releaseDates;
|
||||
}
|
||||
if (genres != null) {
|
||||
_genres = genres;
|
||||
}
|
||||
if (titles != null) {
|
||||
_titles = titles;
|
||||
}
|
||||
if (reviews != null) {
|
||||
_reviews = reviews;
|
||||
}
|
||||
_hasDetails = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "$title (${releaseDate.year}${_genres.isNotEmpty ? "; ${_genres.join(", ")}" : ""})";
|
||||
}
|
||||
|
||||
bool same(MovieData other) {
|
||||
return title == other.title && releaseDate == other.releaseDate;
|
||||
}
|
||||
|
||||
MovieData(this.title, this.releaseDate);
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:release_schedule/api/movie_api.dart';
|
||||
import 'package:release_schedule/api/wikidata_movie_api.dart';
|
||||
import 'package:release_schedule/model/movie.dart';
|
||||
|
||||
T? firstWhereOrNull<T>(List<T> list, bool Function(T element) test) {
|
||||
try {
|
||||
return list.firstWhere(test);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final movieManager = MovieManager(WikidataMovieApi());
|
||||
|
||||
class MovieManager<CustomMovieData extends MovieData> extends ChangeNotifier {
|
||||
final List<CustomMovieData> movies = List.empty(growable: true);
|
||||
final MovieApi<CustomMovieData> api;
|
||||
|
||||
MovieManager(this.api);
|
||||
|
||||
List<CustomMovieData> addMovies(List<CustomMovieData> additionalMovies) {
|
||||
List<CustomMovieData> actualMovies = [];
|
||||
bool added = false;
|
||||
for (var movie in additionalMovies) {
|
||||
CustomMovieData? existing =
|
||||
firstWhereOrNull(movies, (element) => movie.same(element));
|
||||
if (existing == null) {
|
||||
movies.add(movie);
|
||||
added = true;
|
||||
actualMovies.add(movie);
|
||||
} else {
|
||||
actualMovies.add(existing);
|
||||
}
|
||||
}
|
||||
if (added) {
|
||||
notifyListeners();
|
||||
}
|
||||
return actualMovies;
|
||||
}
|
||||
|
||||
/// Only search locally cached movies.
|
||||
localSearch(String search) {}
|
||||
|
||||
/// Online search for movies.
|
||||
Future<List<CustomMovieData>> search(String search) async {
|
||||
List<CustomMovieData> movies = await api.searchForMovies(search);
|
||||
return addMovies(movies);
|
||||
}
|
||||
|
||||
expandDetails(List<CustomMovieData> movies) {
|
||||
api.addMovieDetails(movies);
|
||||
}
|
||||
|
||||
loadUpcomingMovies() async {
|
||||
List<CustomMovieData> movies = await api.getUpcomingMovies();
|
||||
addMovies(movies);
|
||||
}
|
||||
}
|
||||
|
||||
class LiveSearch<CustomMovieData extends MovieData> extends ChangeNotifier {
|
||||
String searchTerm = "";
|
||||
List<CustomMovieData> searchResults = [];
|
||||
Duration minTimeBetweenRequests = const Duration(milliseconds: 500);
|
||||
Duration minTimeAfterChangeToRequest = const Duration(milliseconds: 200);
|
||||
final MovieManager manager;
|
||||
|
||||
LiveSearch(this.manager);
|
||||
|
||||
updateSearch(String search) {
|
||||
searchTerm = search;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:release_schedule/model/movie.dart';
|
||||
|
||||
String durationApproximatedInWords(Duration duration) {
|
||||
int seconds = duration.inSeconds;
|
||||
int minutes = duration.inMinutes;
|
||||
int hours = duration.inHours;
|
||||
int days = duration.inDays;
|
||||
int weeks = (days / 7).floor();
|
||||
int months = (days / 30).floor();
|
||||
int years = (days / 365).floor();
|
||||
int centuries = (years / 100).floor();
|
||||
if (duration == Duration.zero) {
|
||||
return "now";
|
||||
}
|
||||
if (seconds == 0) {
|
||||
return "now";
|
||||
}
|
||||
if (seconds < 60) {
|
||||
return seconds > 1 ? "$seconds seconds" : "a second";
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return minutes > 1 ? "$minutes minutes" : "a minute";
|
||||
}
|
||||
if (hours < 24) {
|
||||
return hours > 1 ? "$hours hours" : "an hour";
|
||||
}
|
||||
if (days < 7) {
|
||||
return days > 1 ? "$days days" : "a day";
|
||||
}
|
||||
if (months == 0) {
|
||||
return weeks > 1 ? "$weeks weeks" : "a week";
|
||||
}
|
||||
if (years == 0) {
|
||||
return months > 1 ? "$months months" : "a month";
|
||||
}
|
||||
if (years < 100) {
|
||||
return years > 1 ? "$years years" : "a year";
|
||||
}
|
||||
return centuries > 1 ? "$centuries centuries" : "a century";
|
||||
}
|
||||
|
||||
String durationToRelativeTimeString(Duration duration) {
|
||||
if (duration.isNegative) {
|
||||
return "${durationApproximatedInWords(-duration)} ago";
|
||||
} else {
|
||||
return "in ${durationApproximatedInWords(duration)}";
|
||||
}
|
||||
}
|
||||
|
||||
String dateRelativeToNow(DateTime date) {
|
||||
DateTime dateOnly = DateTime.utc(date.year, date.month, date.day);
|
||||
DateTime now = DateTime.now().toUtc();
|
||||
DateTime today = DateTime.utc(now.year, now.month, now.day);
|
||||
Duration diff = dateOnly.difference(today);
|
||||
return durationToRelativeTimeString(diff);
|
||||
}
|
||||
|
||||
class MovieItem extends StatelessWidget {
|
||||
final MovieData movie;
|
||||
const MovieItem(this.movie, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final format = DateFormat(DateFormat.YEAR_MONTH_DAY);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: movie,
|
||||
builder: (context, widget) {
|
||||
return ListTile(
|
||||
title: Text(movie.title),
|
||||
subtitle: Text(
|
||||
"${dateRelativeToNow(movie.releaseDate)}, ${format.format(movie.releaseDate)}"));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:release_schedule/model/movie.dart';
|
||||
import 'package:release_schedule/view/movie_item.dart';
|
||||
|
||||
class MovieList extends StatelessWidget {
|
||||
final List<MovieData> movies;
|
||||
const MovieList(this.movies, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(Object context) {
|
||||
return ListView.builder(
|
||||
itemCount: movies.length,
|
||||
itemBuilder: (context, index) {
|
||||
return MovieItem(movies[index]);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
32
pubspec.lock
32
pubspec.lock
|
@ -75,6 +75,30 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.18.1"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -168,6 +192,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.2"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
@ -35,6 +35,8 @@ dependencies:
|
|||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.2
|
||||
http: ^1.1.0
|
||||
intl: ^0.18.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
Loading…
Reference in New Issue