feature: add search

fix: ignore deprecated release dates
main
daniel-michel 2024-01-08 21:48:13 +01:00
parent f698ebcfbe
commit 0caee992ec
11 changed files with 464 additions and 74 deletions

View File

@ -25,10 +25,11 @@ SELECT
?movie ?movie
(MIN(?releaseDate) as ?minReleaseDate) (MIN(?releaseDate) as ?minReleaseDate)
WHERE { WHERE {
?movie wdt:P31 wd:Q11424; # Q11424 is the item for "film" ?movie wdt:P31 wd:Q18011172;
wdt:P577 ?releaseDate. # P577 is the "publication date" property p:P577/psv:P577 [wikibase:timePrecision ?precision];
?movie p:P577/psv:P577 [wikibase:timePrecision ?precision]. wdt:P577 ?releaseDate.
FILTER (xsd:date(?releaseDate) >= xsd:date("$date"^^xsd:dateTime) && ?precision >= 10) FILTER (xsd:date(?releaseDate) >= xsd:date("$date"^^xsd:dateTime))
FILTER (?precision >= 10)
} }
GROUP BY ?movie GROUP BY ?movie
ORDER BY ?minReleaseDate ORDER BY ?minReleaseDate
@ -38,4 +39,6 @@ Where `$limit` is the maximum number of movies that are retrieved and `$date` th
`$limit` is currently set to 100 and `$date` one week before the current one. `$limit` is currently set to 100 and `$date` one week before the current one.
However, because there are multiple publication dates for most movies, the retrieved movies just need to have one publication date that is on or after `$date` for the movie to be included in the result. The `minReleaseDate` is not necessarily the release date displayed in the app, therefore some movies in the app might show up as having been released a long time ago. However, because there are multiple publication dates for most movies, the retrieved movies just need to have one publication date that is on or after `$date` for the movie to be included in the result. The `minReleaseDate` is not necessarily the release date displayed in the app, therefore some movies in the app might show up as having been released a long time ago.
The wd:Q18011172 is a "film project" these are films that are unpublished uor unfinished, but films that release soon are usually finished and might already be released in some countries and might instead be wd:Q11424 "film". Therefore the query is run for each of these categories.
To get additional information about the movies and all release dates (in case some are before `$date` and some after) the API endpoint "https://www.wikidata.org/w/api.php?action=wbgetentities" is used. To get additional information about the movies and all release dates (in case some are before `$date` and some after) the API endpoint "https://www.wikidata.org/w/api.php?action=wbgetentities" is used.

View File

@ -7,7 +7,7 @@
/// ///
/// Returns an [Iterable] of the selected values. /// Returns an [Iterable] of the selected values.
/// ///
/// Also see [selectInJsonWithPath] for a version that returns the path to the selected values /// Also see [selectInJsonWithPath] for a version that returns the path to the selected values.
Iterable<T> selectInJson<T>(dynamic json, String path) { Iterable<T> selectInJson<T>(dynamic json, String path) {
return selectInJsonWithPath<T>(json, path).map((e) => e.value); return selectInJsonWithPath<T>(json, path).map((e) => e.value);
} }
@ -67,7 +67,7 @@ Iterable<({T value, String path})> selectInJsonWithPath<T>(
} else if (json is Map) { } else if (json is Map) {
dynamic value = json[first]; dynamic value = json[first];
if (value != null) { if (value != null) {
yield* selectInJsonWithPath<T>(value, pathParts.join(".")); yield* selectInJsonWithPath<T>(value, rest);
} }
} }
} }

View File

@ -27,6 +27,11 @@ class WikidataProperties {
static const String placeOfPublication = "P291"; static const String placeOfPublication = "P291";
} }
class WikidataEntities {
static const String film = "Q11424";
static const String filmProject = "Q18011172";
}
ApiManager _wikidataApi = ApiManager _wikidataApi =
ApiManager("https://www.wikidata.org/w/api.php?origin=*"); ApiManager("https://www.wikidata.org/w/api.php?origin=*");
@ -43,19 +48,26 @@ class WikidataMovieApi implements MovieApi {
@override @override
Future<List<WikidataMovieData>> getUpcomingMovies(DateTime startDate, Future<List<WikidataMovieData>> getUpcomingMovies(DateTime startDate,
[int count = 100]) async { [int count = 100]) async {
Response response = await queryApi.get( Response filmResponse = await queryApi.get(
"&query=${Uri.encodeComponent(_createUpcomingMovieQuery(startDate, count))}"); "&query=${Uri.encodeComponent(_createUpcomingMovieQuery(startDate, WikidataEntities.film, count))}");
if (response.statusCode != 200) { Response filmProjectResponse = await queryApi.get(
throw Exception( "&query=${Uri.encodeComponent(_createUpcomingMovieQuery(startDate, WikidataEntities.filmProject, count))}");
"The Wikidata request for upcoming movies failed with status ${response.statusCode} ${response.reasonPhrase}"); List<Response> responses = [filmResponse, filmProjectResponse];
for (var response in responses) {
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); Iterable<Map<String, dynamic>> results =
List<dynamic> entries = result["results"]["bindings"]; responses.map((response) => jsonDecode(response.body));
Iterable<dynamic> entries =
results.expand((result) => result["results"]["bindings"]);
List<String> ids = entries List<String> ids = entries
.map((entry) => .map((entry) =>
RegExp(r"Q\d+$").firstMatch(entry["movie"]["value"])![0]!) RegExp(r"Q\d+$").firstMatch(entry["movie"]["value"])![0]!)
.toList(); .toList();
return _getMovieDataFromIds(ids); return await _getMovieDataFromIds(ids);
} }
Future<List<WikidataMovieData>> _getMovieDataFromIds( Future<List<WikidataMovieData>> _getMovieDataFromIds(
@ -92,9 +104,20 @@ class WikidataMovieApi implements MovieApi {
} }
@override @override
Future<List<WikidataMovieData>> searchForMovies(String searchTerm) { Future<List<WikidataMovieData>> searchForMovies(String searchTerm) async {
// TODO: implement searchForMovies String haswbstatement =
throw UnimplementedError(); "haswbstatement:${WikidataProperties.instanceOf}=${WikidataEntities.film}|${WikidataProperties.instanceOf}=${WikidataEntities.filmProject}";
String query =
"&action=query&list=search&format=json&srsearch=${Uri.encodeComponent(searchTerm)}%20$haswbstatement";
Response result = await _wikidataApi.get(query);
Map<String, dynamic> json = jsonDecode(result.body);
List<Map<String, dynamic>> searchResults =
selectInJson<Map<String, dynamic>>(json, "query.search.*").toList();
List<String> ids = searchResults
.map((result) => result["title"] as String)
.where((title) => RegExp(r"^Q\d+$").hasMatch(title))
.toList();
return await _getMovieDataFromIds(ids);
} }
} }
@ -133,6 +156,7 @@ class WikidataMovieData extends MovieData {
.toList(); .toList();
List<DateWithPrecisionAndCountry> releaseDates = List<DateWithPrecisionAndCountry> releaseDates =
selectInJson(claims, "${WikidataProperties.publicationDate}.*") selectInJson(claims, "${WikidataProperties.publicationDate}.*")
.where((dateClaim) => dateClaim["rank"] != "deprecated")
.map<DateWithPrecisionAndCountry>((dateClaim) { .map<DateWithPrecisionAndCountry>((dateClaim) {
var value = selectInJson(dateClaim, "mainsnak.datavalue.value").first; var value = selectInJson(dateClaim, "mainsnak.datavalue.value").first;
String country = _getCachedLabelForEntity(selectInJson<String>(dateClaim, String country = _getCachedLabelForEntity(selectInJson<String>(dateClaim,
@ -149,8 +173,13 @@ class WikidataMovieData extends MovieData {
claims, "${WikidataProperties.genre}.*.mainsnak.datavalue.value.id") claims, "${WikidataProperties.genre}.*.mainsnak.datavalue.value.id")
.map(_getCachedLabelForEntity) .map(_getCachedLabelForEntity)
.toList(); .toList();
WikidataMovieData movie = WikidataMovieData movie = WikidataMovieData(
WikidataMovieData(title, releaseDates[0], entityId); title,
releaseDates.isNotEmpty
? releaseDates[0]
: DateWithPrecisionAndCountry(
DateTime.now(), DatePrecision.decade, "unknown location"),
entityId);
movie.setDetails( movie.setDetails(
titles: titles, titles: titles,
releaseDates: releaseDates, releaseDates: releaseDates,
@ -160,17 +189,19 @@ class WikidataMovieData extends MovieData {
} }
} }
String _createUpcomingMovieQuery(DateTime startDate, int limit) { String _createUpcomingMovieQuery(
DateTime startDate, String instanceOf, int limit) {
String date = DateFormat("yyyy-MM-dd").format(startDate); String date = DateFormat("yyyy-MM-dd").format(startDate);
return """ return """
SELECT SELECT
?movie ?movie
(MIN(?releaseDate) as ?minReleaseDate) (MIN(?releaseDate) as ?minReleaseDate)
WHERE { WHERE {
?movie wdt:P31 wd:Q11424; # Q11424 is the item for "film" ?movie wdt:${WikidataProperties.instanceOf} wd:$instanceOf;
wdt:P577 ?releaseDate. # P577 is the "publication date" property wdt:${WikidataProperties.publicationDate} ?releaseDate.
?movie p:P577/psv:P577 [wikibase:timePrecision ?precision]. ?movie p:${WikidataProperties.publicationDate}/psv:${WikidataProperties.publicationDate} [wikibase:timePrecision ?precision].
FILTER (xsd:date(?releaseDate) >= xsd:date("$date"^^xsd:dateTime) && ?precision >= 10) FILTER (xsd:date(?releaseDate) >= xsd:date("$date"^^xsd:dateTime))
FILTER (?precision >= 10)
} }
GROUP BY ?movie GROUP BY ?movie
ORDER BY ?minReleaseDate ORDER BY ?minReleaseDate

View File

@ -1,9 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:release_schedule/api/movie_api.dart'; import 'package:release_schedule/model/live_search.dart';
import 'package:release_schedule/api/wikidata_movie_api.dart';
import 'package:release_schedule/model/movie.dart'; import 'package:release_schedule/model/movie.dart';
import 'package:release_schedule/model/movie_manager.dart'; import 'package:release_schedule/model/movie_manager.dart';
import 'package:release_schedule/view/movie_list.dart';
import 'package:release_schedule/view/movie_manager_list.dart'; import 'package:release_schedule/view/movie_manager_list.dart';
import 'package:release_schedule/view/swipe-transition.dart';
void main() { void main() {
runApp(const MyApp()); runApp(const MyApp());
@ -27,53 +28,140 @@ class MyApp extends StatelessWidget {
} }
} }
class HomePage extends StatelessWidget { class HomePage extends StatefulWidget {
final MovieApi api = WikidataMovieApi();
final MovieManager manager; final MovieManager manager;
HomePage(this.manager, {super.key}); HomePage(this.manager, {super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late LiveSearch liveSearch;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this, // the SingleTickerProviderStateMixin
duration: const Duration(milliseconds: 300),
);
liveSearch = LiveSearch(widget.manager);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text("Release Schedule"), title: TextField(
actions: [HamburgerMenu(manager)], decoration: const InputDecoration(
), hintText: "Search",
body: DefaultTabController( border: InputBorder.none,
length: 2, ),
child: Column( onChanged: (value) {
children: [ setState(() {
Expanded( if (value.isEmpty) {
child: TabBarView( _controller.reverse();
children: [ } else {
Scaffold( _controller.forward();
body: MovieManagerList( }
manager, liveSearch.updateSearch(value);
// Only show movies that are bookmarked or have a release date with at least month precision and at least one title });
filter: (movie) => },
movie.bookmarked ||
(movie.releaseDate.precision >= DatePrecision.month &&
(movie.titles?.length ?? 0) >= 1),
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.refresh),
onPressed: () => manager.loadUpcomingMovies(),
),
),
MovieManagerList(
manager,
filter: (movie) => movie.bookmarked,
)
],
),
),
const TabBar(tabs: [
Tab(icon: Icon(Icons.list), child: Text("Upcoming")),
Tab(icon: Icon(Icons.bookmark), child: Text("Bookmarked")),
]),
],
), ),
actions: [HamburgerMenu(widget.manager)],
),
body: SwipeTransition(
animation: _controller,
first: OverviewPage(manager: widget.manager),
second: SearchResultPage(liveSearch: liveSearch),
),
);
}
}
class SearchResultPage extends StatelessWidget {
const SearchResultPage({
super.key,
required this.liveSearch,
});
final LiveSearch liveSearch;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: liveSearch,
builder: (context, child) {
return MovieList(liveSearch.searchResults);
},
);
}
}
class OverviewPage extends StatelessWidget {
const OverviewPage({
super.key,
required this.manager,
});
final MovieManager manager;
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Column(
children: [
Expanded(
child: TabBarView(
children: [
Scaffold(
body: MovieManagerList(
manager,
// Only show movies that are bookmarked or have a release date with at least month precision and at least one title
filter: (movie) =>
movie.bookmarked ||
(movie.releaseDate.precision >= DatePrecision.month &&
(movie.titles?.length ?? 0) >= 1),
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.refresh),
onPressed: () async {
var scaffold = ScaffoldMessenger.of(context);
try {
await manager.loadUpcomingMovies();
} catch (e) {
scaffold.showSnackBar(
const SnackBar(
content: Text("Failed to load upcoming movies"),
),
);
}
},
),
),
MovieManagerList(
manager,
filter: (movie) => movie.bookmarked,
)
],
),
),
const TabBar(tabs: [
Tab(icon: Icon(Icons.list), child: Text("Upcoming")),
Tab(icon: Icon(Icons.bookmark), child: Text("Bookmarked")),
]),
],
), ),
); );
} }
@ -89,6 +177,16 @@ class HamburgerMenu extends StatelessWidget {
icon: const Icon(Icons.menu), icon: const Icon(Icons.menu),
itemBuilder: (context) { itemBuilder: (context) {
return [ return [
PopupMenuItem(
child: const Text("Remove irrelevant"),
onTap: () => manager.removeMoviesWhere((movie) =>
!movie.bookmarked &&
!(movie.releaseDates?.any((date) =>
date.precision >= DatePrecision.month &&
date.date.isAfter(DateTime.now()
.subtract(const Duration(days: 30)))) ??
false)),
),
PopupMenuItem( PopupMenuItem(
child: const Text("Remove all not bookmarked"), child: const Text("Remove all not bookmarked"),
onTap: () => onTap: () =>

View File

@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:release_schedule/model/delayed_function_caller.dart';
import 'package:release_schedule/model/movie.dart';
import 'package:release_schedule/model/movie_manager.dart';
class LiveSearch extends ChangeNotifier {
String searchTerm = "";
List<MovieData> searchResults = [];
Duration minTimeBetweenRequests = const Duration(milliseconds: 200);
late final DelayedFunctionCaller _searchCaller;
final MovieManager manager;
bool searchingOnline = false;
LiveSearch(this.manager) {
_searchCaller = DelayedFunctionCaller(searchOnline, minTimeBetweenRequests);
}
void updateSearch(String search) {
searchTerm = search;
if (searchTerm.isEmpty) {
return;
}
searchResults = manager.localSearch(search);
_searchCaller.call();
notifyListeners();
}
void searchOnline() async {
if (searchTerm.isEmpty) {
return;
}
if (searchingOnline) {
_searchCaller.call();
return;
}
searchingOnline = true;
try {
String startedSearching = searchTerm;
List<MovieData> onlineResults =
await movieManager.onlineSearch(searchTerm);
searchingOnline = false;
// if the search term has changed since we started searching, ignore the results
if (startedSearching != searchTerm) {
return;
}
List<MovieData> localResults = manager.localSearch(searchTerm);
localResults.removeWhere((element) => onlineResults.contains(element));
searchResults = onlineResults + localResults;
notifyListeners();
} finally {
searchingOnline = false;
}
}
}

View File

@ -6,6 +6,7 @@ import 'package:release_schedule/api/wikidata_movie_api.dart';
import 'package:release_schedule/model/delayed_function_caller.dart'; import 'package:release_schedule/model/delayed_function_caller.dart';
import 'package:release_schedule/model/local_movie_storage.dart'; import 'package:release_schedule/model/local_movie_storage.dart';
import 'package:release_schedule/model/movie.dart'; import 'package:release_schedule/model/movie.dart';
import 'package:release_schedule/model/search.dart';
final movieManager = MovieManager(WikidataMovieApi(), final movieManager = MovieManager(WikidataMovieApi(),
LocalMovieStorageGetStorage(WikidataMovieData.fromEncodable)); LocalMovieStorageGetStorage(WikidataMovieData.fromEncodable));
@ -15,7 +16,7 @@ class MovieManager extends ChangeNotifier {
final LocalMovieStorage cache; final LocalMovieStorage cache;
final MovieApi api; final MovieApi api;
bool loading = false; bool loading = false;
DelayedFunctionCaller? cacheUpdater; late final DelayedFunctionCaller cacheUpdater;
bool cacheLoaded = false; bool cacheLoaded = false;
MovieManager(this.api, this.cache) { MovieManager(this.api, this.cache) {
@ -31,7 +32,7 @@ class MovieManager extends ChangeNotifier {
} }
void _moviesModified({bool withoutAddingOrRemoving = false}) { void _moviesModified({bool withoutAddingOrRemoving = false}) {
cacheUpdater?.call(); cacheUpdater.call();
if (!withoutAddingOrRemoving) { if (!withoutAddingOrRemoving) {
// only notify listeners if movies are added or removed // only notify listeners if movies are added or removed
// if they are modified in place they will notify listeners themselves // if they are modified in place they will notify listeners themselves
@ -108,10 +109,19 @@ class MovieManager extends ChangeNotifier {
} }
/// Only search locally cached movies. /// Only search locally cached movies.
localSearch(String search) {} List<MovieData> localSearch(String search) {
var results = searchList(
movies,
search,
(movie) => [
movie.title,
...(movie.titles?.map((title) => title.title) ?? []),
]);
return results;
}
/// Online search for movies. /// Online search for movies.
Future<List<MovieData>> search(String search) async { Future<List<MovieData>> onlineSearch(String search) async {
List<MovieData> movies = await api.searchForMovies(search); List<MovieData> movies = await api.searchForMovies(search);
return addMovies(movies); return addMovies(movies);
} }

View File

@ -0,0 +1,91 @@
import 'dart:math';
class Scored<T> {
T data;
double score;
Scored(this.data, this.score);
}
List<T> searchList<T>(
List<T> list,
String search,
List<String> Function(T item) getTexts,
) {
List<Scored<T>> scored = list.map((e) {
double score = 0;
List<String> texts = getTexts(e);
for (var text in texts) {
score += searchMatch(search, text);
}
return Scored(e, score);
}).toList();
scored = scored.where((element) => element.score > 0.7).toList();
scored.sort((a, b) => (b.score - a.score).sign.toInt());
return scored.map((e) => e.data).toList();
}
double searchMatch(String search, String text) {
double matchPoints = 0;
List<String> searchParts = [search.toLowerCase()];
List<String> textParts = [text.toLowerCase()];
while (searchParts.isNotEmpty && textParts.isNotEmpty) {
int bestSpi = 0;
int bestTpi = 0;
int bestSci = 0;
int bestTci = 0;
int bestLength = 0;
for (int spi = 0; spi < searchParts.length; spi++) {
String searchPart = searchParts[spi];
for (int tpi = 0; tpi < textParts.length; tpi++) {
String textPart = textParts[tpi];
for (int sci = 0; sci < searchPart.length; sci++) {
for (int tci = 0; tci < textPart.length; tci++) {
int length = 0;
int maxLength = min(searchPart.length - sci, textPart.length - tci);
while (length < maxLength &&
searchPart[sci + length] == textPart[tci + length]) {
length++;
}
if (length > bestLength) {
bestSpi = spi;
bestTpi = tpi;
bestSci = sci;
bestTci = tci;
bestLength = length;
}
}
}
}
}
if (bestLength == 0) break;
matchPoints += bestLength.toDouble() - 0.5;
String searchPart = searchParts[bestSpi];
searchParts.removeAt(bestSpi);
searchParts.addAll([
searchPart.substring(0, bestSci),
searchPart.substring(bestSci + bestLength),
]);
searchParts.removeWhere((x) => x.isEmpty);
String textPart = textParts[bestTpi];
textParts.removeAt(bestTpi);
textParts.addAll([
textPart.substring(0, bestTci),
textPart.substring(bestTci + bestLength),
]);
textParts.removeWhere((x) => x.isEmpty);
}
// normalize result
double matchScore = matchPoints / (search.length - 0.5);
double missCut = pow(
(textParts.fold(0, (acc, part) => acc + part.length) / text.length),
2) *
0.2 *
matchScore;
double score = matchScore - missCut;
return score;
}

View File

@ -16,8 +16,8 @@ class MovieItem extends StatelessWidget {
title: Text(movie.title), title: Text(movie.title),
subtitle: Text( subtitle: Text(
"${dateRelativeToNow(movie.releaseDate.date)}, ${movie.releaseDate.toString()}, ${movie.genres?.join(", ") ?? ""}"), "${dateRelativeToNow(movie.releaseDate.date)}, ${movie.releaseDate.toString()}, ${movie.genres?.join(", ") ?? ""}"),
trailing: TextButton( trailing: IconButton(
child: Icon(movie.bookmarked icon: Icon(movie.bookmarked
? Icons.bookmark_added ? Icons.bookmark_added
: Icons.bookmark_border), : Icons.bookmark_border),
onPressed: () => movie.setDetails(bookmarked: !movie.bookmarked), onPressed: () => movie.setDetails(bookmarked: !movie.bookmarked),

View File

@ -29,9 +29,14 @@ class MoviePage extends StatelessWidget {
animation: movie, animation: movie,
builder: (context, child) { builder: (context, child) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(movie.title), actions: [
title: Text(movie.title), IconButton(
), icon: Icon(movie.bookmarked
? Icons.bookmark_added
: Icons.bookmark_outline),
onPressed: () => movie.setDetails(bookmarked: !movie.bookmarked),
),
]),
body: SingleChildScrollView( body: SingleChildScrollView(
child: Padding( child: Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(12.0),

View File

@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
/// A widget that transitions between two child widget by clipping them so that
/// the first one slides out of view and the second one slides into view.
class SwipeTransition extends StatelessWidget {
final Widget first;
final Widget second;
late final CurvedAnimation firstAnimation;
late final CurvedAnimation secondAnimation;
final Animation<double> animation;
SwipeTransition({
Key? key,
required this.first,
required this.second,
required this.animation,
}) : super(key: key) {
firstAnimation = CurvedAnimation(
parent: animation,
curve: Curves.easeOutSine,
);
secondAnimation = CurvedAnimation(
parent: animation,
curve: Curves.easeInSine,
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Stack(
children: [
ClipWithRect(
clipRect: Rect.fromLTRB(
0,
firstAnimation.value,
1,
1,
),
child: first,
),
ClipWithRect(
clipRect: Rect.fromLTRB(
0,
0,
1,
secondAnimation.value,
),
child: second,
),
],
);
},
);
}
}
class ClipWithRect extends StatelessWidget {
final Widget child;
final Rect clipRect;
const ClipWithRect({
Key? key,
required this.child,
required this.clipRect,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ClipRect(
clipper: _RectClipper(clipRect),
child: child,
);
}
}
class _RectClipper extends CustomClipper<Rect> {
final Rect clipRect;
_RectClipper(this.clipRect);
@override
Rect getClip(Size size) {
return Rect.fromLTRB(
clipRect.left * size.width,
clipRect.top * size.height,
clipRect.right * size.width,
clipRect.bottom * size.height,
);
}
@override
bool shouldReclip(covariant CustomClipper<Rect> oldClipper) {
return true;
}
}

View File

@ -12,7 +12,7 @@ void main() {
MovieManager movieManager = MovieManager(MovieApi(), LocalMovieStorage()); MovieManager movieManager = MovieManager(MovieApi(), LocalMovieStorage());
await tester.pumpWidget(MaterialApp(home: HomePage(movieManager))); await tester.pumpWidget(MaterialApp(home: HomePage(movieManager)));
expect(find.text('Release Schedule'), findsOneWidget); expect(find.text('Search'), findsOneWidget);
}); });
testWidgets('displays list of releases', (WidgetTester tester) async { testWidgets('displays list of releases', (WidgetTester tester) async {