Compare commits

...

17 Commits

Author SHA1 Message Date
daniel-michel 9aa0278ab0 update readme with new screenshots 2024-01-10 00:21:19 +01:00
daniel-michel be11dc040c test: more testing of the home page, MovieItem, MovieList
test: add testing for MoviePage
fix: remove global accidentally used instance of movie manager
2024-01-09 21:52:32 +01:00
daniel-michel 57708bc894 test: dates with precision
test: selectInJson
refactor: remove unused selectMultipleInJson and date relative date formatting
2024-01-09 21:02:18 +01:00
daniel-michel 0a9a8d033f refactor: update theme 2024-01-09 19:17:59 +01:00
daniel-michel ed5b537550 fix: show no movies when none of the movies is allowed by the filter 2024-01-09 19:16:48 +01:00
daniel-michel 642f5b70a2 fix: use own grouped list
the grouped list from sticky_grouped_list has some problems
2024-01-09 18:49:46 +01:00
daniel-michel 6961c744a7 fix: add multiple locations for one publication date 2024-01-09 16:36:08 +01:00
daniel-michel dfde4d0aea feature: start movie list at current date
refactor: move DatePrecision and related logic into separate file
2024-01-09 16:32:53 +01:00
daniel-michel 698c785896 add: group movies by release date 2024-01-09 14:48:36 +01:00
daniel-michel 497c2e6d2e refactor: use short names for genres 2024-01-09 13:24:35 +01:00
daniel-michel a0e4edb508 fix: accurate loading indicator for search, add button to clear search 2024-01-09 12:47:42 +01:00
daniel-michel d5861bdb78 fix: uncommitted changes, add: loading indicator for search 2024-01-08 21:58:18 +01:00
daniel-michel 0caee992ec feature: add search
fix: ignore deprecated release dates
2024-01-08 21:48:13 +01:00
daniel-michel f698ebcfbe fix: update query in readme
refactor: only show movies with at least one title in upcoming movies
2024-01-08 14:57:03 +01:00
daniel-michel 0ea9aef7be feature: add page for displaying a movie
fix: only load and show upcoming movies with at least month date precision
2024-01-08 14:30:32 +01:00
daniel-michel 618f5d135b feature: add bookmarking functionality 2024-01-08 12:57:36 +01:00
daniel-michel 688fa63da2 refactor 2024-01-08 11:56:35 +01:00
34 changed files with 1627 additions and 357 deletions

View File

@ -6,12 +6,18 @@ You can try out the live web version at [daniel-michel.github.io/release_schedul
Android, Linux and Web builds can be found in the latest [CI run](https://github.com/daniel-michel/release_schedule/actions/workflows/ci.yml). Android, Linux and Web builds can be found in the latest [CI run](https://github.com/daniel-michel/release_schedule/actions/workflows/ci.yml).
Currently, only a simple list of upcoming movies is shown: ## Overview
![](screenshots/movie_list.png) There are two screens that show upcoming movies and bookmarked movies:
The floating button at the bottom right can be used to load the upcoming movies and the button at the top right to clear the movies that where already loaded. <img src="screenshots/upcoming.png" width="300">
<img src="screenshots/bookmarks.png" width="300">
The floating button at the bottom right of the upcoming movies list can be used to load new upcoming movies. The menu at the top right can be used to clear some or all of the cached movies.
The movies that are cached as well as other movies can be searched with the search field at the top:
<img src="screenshots/search.png" width="300">
## Wikidata API ## Wikidata API
@ -20,21 +26,27 @@ The Implementation can be found at [./lib/api/wikidata_movie_api.dart](./lib/api
To get information about the upcoming movies multiple APIs are used. To get information about the upcoming movies multiple APIs are used.
First the SPARQL API is used to retrieve upcoming movies using the endpoint "https://query.wikidata.org/sparql" with the following query: First the SPARQL API is used to retrieve upcoming movies using the endpoint "https://query.wikidata.org/sparql" with the following query:
```sql ```sql
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:P31 wd:Q18011172;
wdt:P577 ?releaseDate. # P577 is the "publication date" property p:P577/psv:P577 [wikibase:timePrecision ?precision];
wdt:P577 ?releaseDate.
FILTER (xsd:date(?releaseDate) >= xsd:date("$date"^^xsd:dateTime)) FILTER (xsd:date(?releaseDate) >= xsd:date("$date"^^xsd:dateTime))
FILTER (?precision >= 10)
} }
GROUP BY ?movie GROUP BY ?movie
ORDER BY ?minReleaseDate ORDER BY ?minReleaseDate
LIMIT $limit LIMIT $limit
``` ```
Where `$limit` is the maximum number of movies that are retrieved and `$date` the starting date from which movies are retrieved. Where `$limit` is the maximum number of movies that are retrieved and `$date` the starting date from which movies are retrieved.
`$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,20 +7,11 @@
/// ///
/// 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);
} }
Map<String, Iterable<dynamic>> selectMultipleInJson(
dynamic json, Map<String, String> selector) {
Map<String, Iterable<dynamic>> result = {};
for (String key in selector.keys) {
result[key] = selectInJsonWithPath(json, selector[key]!);
}
return result;
}
/// Select values in nested [List] and [Map] structures using a path that may contain wildcards. /// Select values in nested [List] and [Map] structures using a path that may contain wildcards.
/// ///
/// The maps must always use [String] keys. /// The maps must always use [String] keys.
@ -40,13 +31,18 @@ Iterable<({T value, String path})> selectInJsonWithPath<T>(
List<String> pathParts = path.split("."); List<String> pathParts = path.split(".");
String first = pathParts.removeAt(0); String first = pathParts.removeAt(0);
String rest = pathParts.join("."); String rest = pathParts.join(".");
addFirstToPath(({T value, String path}) element) => ( ({T value, String path}) addFirstToPath(({T value, String path}) element) {
return (
value: element.value, value: element.value,
path: element.path.isEmpty ? first : "$first.${element.path}" path: element.path.isEmpty ? first : "$first.${element.path}"
); );
}
if (first == "*" || first == "**") { if (first == "*" || first == "**") {
String continueWithPath = first == "*" ? rest : path; String continueWithPath = first == "*" ? rest : path;
if (first == "**") {
yield* selectInJsonWithPath<T>(json, rest);
}
if (json is List) { if (json is List) {
yield* json yield* json
.expand((e) => selectInJsonWithPath<T>(e, continueWithPath)) .expand((e) => selectInJsonWithPath<T>(e, continueWithPath))
@ -67,7 +63,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

@ -6,6 +6,4 @@ class MovieApi {
[]; [];
Future<List<MovieData>> searchForMovies(String searchTerm) async => []; Future<List<MovieData>> searchForMovies(String searchTerm) async => [];
Future<void> addMovieDetails(List<MovieData> movies) async {}
} }

View File

@ -6,6 +6,7 @@ import 'package:intl/intl.dart';
import 'package:release_schedule/api/api_manager.dart'; import 'package:release_schedule/api/api_manager.dart';
import 'package:release_schedule/api/json_helper.dart'; import 'package:release_schedule/api/json_helper.dart';
import 'package:release_schedule/api/movie_api.dart'; import 'package:release_schedule/api/movie_api.dart';
import 'package:release_schedule/model/dates.dart';
import 'package:release_schedule/model/movie.dart'; import 'package:release_schedule/model/movie.dart';
class WikidataProperties { class WikidataProperties {
@ -25,6 +26,12 @@ class WikidataProperties {
static const String reviewScore = "P444"; static const String reviewScore = "P444";
static const String fskFilmRating = "P1981"; static const String fskFilmRating = "P1981";
static const String placeOfPublication = "P291"; static const String placeOfPublication = "P291";
static const String shortName = "P1813";
}
class WikidataEntities {
static const String film = "Q11424";
static const String filmProject = "Q18011172";
} }
ApiManager _wikidataApi = ApiManager _wikidataApi =
@ -34,28 +41,29 @@ class WikidataMovieApi implements MovieApi {
ApiManager queryApi = ApiManager queryApi =
ApiManager("https://query.wikidata.org/sparql?format=json&origin=*"); ApiManager("https://query.wikidata.org/sparql?format=json&origin=*");
@override
Future<void> addMovieDetails(List<MovieData> movies) {
// TODO: implement addMovieDetails
throw UnimplementedError();
}
@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))}");
Response filmProjectResponse = await queryApi.get(
"&query=${Uri.encodeComponent(_createUpcomingMovieQuery(startDate, WikidataEntities.filmProject, count))}");
List<Response> responses = [filmResponse, filmProjectResponse];
for (var response in responses) {
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception( throw Exception(
"The Wikidata request for upcoming movies failed with status ${response.statusCode} ${response.reasonPhrase}"); "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"]; Iterable<Map<String, dynamic>> results =
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 +100,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);
} }
} }
@ -132,43 +151,62 @@ class WikidataMovieData extends MovieData {
) as TitleInLanguage) ) as TitleInLanguage)
.toList(); .toList();
List<DateWithPrecisionAndCountry> releaseDates = List<DateWithPrecisionAndCountry> releaseDates =
selectInJson(claims, "${WikidataProperties.publicationDate}.*") _getReleaseDates(claims).toList();
.map<DateWithPrecisionAndCountry>((dateClaim) {
var value = selectInJson(dateClaim, "mainsnak.datavalue.value").first;
String country = _getCachedLabelForEntity(selectInJson<String>(dateClaim,
"qualifiers.${WikidataProperties.placeOfPublication}.*.datavalue.value.id")
.firstOrNull ??
"no country");
return DateWithPrecisionAndCountry(DateTime.parse(value["time"]),
_precisionFromWikidata(value["precision"]), country);
}).toList();
// Sort release dates with higher precision to the beginning // Sort release dates with higher precision to the beginning
releaseDates releaseDates.sort((a, b) => -a.dateWithPrecision.precision.index
.sort((a, b) => -a.precision.index.compareTo(b.precision.index)); .compareTo(b.dateWithPrecision.precision.index));
List<String>? genres = selectInJson<String>( List<String>? genres = selectInJson<String>(
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,
genres: genres, genres: genres,
); );
return movie; return movie;
} }
static Iterable<DateWithPrecisionAndCountry> _getReleaseDates(
Map<String, dynamic> claims) {
return selectInJson(claims, "${WikidataProperties.publicationDate}.*")
.where((dateClaim) => dateClaim["rank"] != "deprecated")
.expand<DateWithPrecisionAndCountry>((dateClaim) {
var value = selectInJson(dateClaim, "mainsnak.datavalue.value").first;
Iterable<String> countries = (selectInJson<String>(dateClaim,
"qualifiers.${WikidataProperties.placeOfPublication}.*.datavalue.value.id"))
.map(_getCachedLabelForEntity);
if (countries.isEmpty) {
countries = ["unknown location"];
}
return countries.map((country) => DateWithPrecisionAndCountry(
DateTime.parse(value["time"]),
_precisionFromWikidata(value["precision"]),
country));
});
}
} }
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:${WikidataProperties.publicationDate}/psv:${WikidataProperties.publicationDate} [wikibase:timePrecision ?precision].
FILTER (xsd:date(?releaseDate) >= xsd:date("$date"^^xsd:dateTime)) FILTER (xsd:date(?releaseDate) >= xsd:date("$date"^^xsd:dateTime))
FILTER (?precision >= 10)
} }
GROUP BY ?movie GROUP BY ?movie
ORDER BY ?minReleaseDate ORDER BY ?minReleaseDate
@ -203,16 +241,24 @@ Future<Map<String, String>> _getLabelsForEntities(
final start = i * batchSize; final start = i * batchSize;
final end = min((i + 1) * batchSize, entityIds.length); final end = min((i + 1) * batchSize, entityIds.length);
Response response = await _wikidataApi.get( Response response = await _wikidataApi.get(
"&action=wbgetentities&format=json&props=labels&ids=${entityIds.sublist(start, end).join("|")}"); "&action=wbgetentities&format=json&props=labels|claims&ids=${entityIds.sublist(start, end).join("|")}");
Map<String, dynamic> result = jsonDecode(response.body); Map<String, dynamic> result = jsonDecode(response.body);
Map<String, dynamic> batchEntities = result["entities"]; Map<String, dynamic> batchEntities = result["entities"];
for (String entityId in batchEntities.keys) { for (String entityId in batchEntities.keys) {
Map<String, dynamic> labels = batchEntities[entityId]["labels"]; String? shortName = selectInJson(batchEntities[entityId],
String label = labels.containsKey("en") "claims.${WikidataProperties.shortName}.*.mainsnak.datavalue.value")
? labels["en"]["value"] .where((value) => value["language"] == "en")
: labels[labels.keys.first]["value"]; .map((value) => (value["text"] as String))
labels[entityId] = label; .firstOrNull;
_labelCache[entityId] = label; Map<String, dynamic> responseLabels = batchEntities[entityId]["labels"];
if (shortName != null) {
_labelCache[entityId] = labels[entityId] = shortName;
continue;
}
String label = responseLabels.containsKey("en")
? responseLabels["en"]["value"]
: responseLabels[responseLabels.keys.first]["value"];
_labelCache[entityId] = labels[entityId] = label;
} }
} }
return labels; return labels;

View File

@ -1,8 +1,12 @@
import 'package:flutter/material.dart'; 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/api/wikidata_movie_api.dart';
import 'package:release_schedule/model/dates.dart';
import 'package:release_schedule/model/live_search.dart';
import 'package:release_schedule/model/local_movie_storage.dart';
import 'package:release_schedule/model/movie_manager.dart'; import 'package:release_schedule/model/movie_manager.dart';
import 'package:release_schedule/view/movie_item.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());
@ -16,38 +20,238 @@ class MyApp extends StatelessWidget {
return MaterialApp( return MaterialApp(
title: 'Movie Schedule', title: 'Movie Schedule',
themeMode: ThemeMode.dark, themeMode: ThemeMode.dark,
darkTheme: ThemeData.dark(useMaterial3: true), darkTheme: ThemeData.dark(
useMaterial3: true,
).copyWith(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepOrange,
brightness: Brightness.dark,
),
),
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
useMaterial3: true, useMaterial3: true,
), ),
home: HomePage(movieManager), home: HomePage(
MovieManager(WikidataMovieApi(),
LocalMovieStorageGetStorage(WikidataMovieData.fromEncodable)),
),
); );
} }
} }
class HomePage extends StatelessWidget { class HomePage extends StatefulWidget {
final MovieApi api = WikidataMovieApi();
final MovieManager manager; final MovieManager manager;
HomePage(this.manager, {super.key}); const HomePage(this.manager, {super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late LiveSearch liveSearch;
late TextEditingController _searchController;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this, // the SingleTickerProviderStateMixin
duration: const Duration(milliseconds: 300),
);
_searchController = TextEditingController();
liveSearch = LiveSearch(widget.manager);
}
@override
void dispose() {
_controller.dispose();
_searchController.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"), elevation: 1,
actions: [ title: Row(
FilledButton( children: [
onPressed: () => manager.removeMoviesWhere((movie) => true), Expanded(
child: const Icon(Icons.delete)) child: TextField(
controller: _searchController,
decoration: const InputDecoration(
hintText: "Search",
border: InputBorder.none,
),
onChanged: (value) {
setState(() {
if (value.isEmpty) {
_controller.reverse();
} else {
_controller.forward();
}
liveSearch.updateSearch(value);
});
},
),
),
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
if (liveSearch.searchTerm.isEmpty) return Container();
return IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
liveSearch.updateSearch("");
_controller.reverse();
},
);
},
),
], ],
), ),
body: MovieManagerList(manager), actions: [HamburgerMenu(widget.manager)],
floatingActionButton: FloatingActionButton( ),
child: const Icon(Icons.refresh), body: SwipeTransition(
onPressed: () => manager.loadUpcomingMovies(), 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 Column(
children: [
liveSearch.loading ? const LinearProgressIndicator() : Container(),
Expanded(
child: ListView.builder(
itemCount: liveSearch.searchResults.length,
itemBuilder: (context, index) => MovieItem(
liveSearch.searchResults[index],
showReleaseDate: true,
),
),
),
],
);
},
);
}
}
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: [
const TabBar(
tabs: [
Tab(icon: Icon(Icons.list), child: Text("Upcoming")),
Tab(icon: Icon(Icons.bookmark), child: Text("Bookmarked")),
],
),
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.dateWithPrecision.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,
)
],
),
),
],
),
);
}
}
class HamburgerMenu extends StatelessWidget {
final MovieManager manager;
const HamburgerMenu(this.manager, {super.key});
@override
Widget build(BuildContext context) {
return PopupMenuButton(
icon: const Icon(Icons.menu),
itemBuilder: (context) {
return [
PopupMenuItem(
child: const Text("Remove irrelevant"),
onTap: () => manager.removeMoviesWhere((movie) =>
!movie.bookmarked &&
!(movie.releaseDates?.any((date) =>
date.dateWithPrecision.precision >=
DatePrecision.month &&
date.dateWithPrecision.date.isAfter(DateTime.now()
.subtract(const Duration(days: 30)))) ??
false)),
),
PopupMenuItem(
child: const Text("Remove all not bookmarked"),
onTap: () =>
manager.removeMoviesWhere((movie) => !movie.bookmarked),
),
PopupMenuItem(
child: const Text("Remove all"),
onTap: () => manager.removeMoviesWhere((movie) => true),
),
];
},
);
}
}

View File

@ -1,48 +0,0 @@
/// Compares dates relative to each other. Times are ignored.
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 _durationToRelativeDateString(diff);
}
String _durationToRelativeDateString(Duration duration) {
if (duration == const Duration(days: 1)) {
return "Tomorrow";
} else if (duration == const Duration(days: -1)) {
return "Yesterday";
}
if (duration.isNegative) {
String result = _durationApproximatedInWords(-duration);
return "${result[0].toUpperCase()}${result.substring(1)} ago";
} else if (duration == Duration.zero) {
return "Today";
} else {
return "In ${_durationApproximatedInWords(duration)}";
}
}
String _durationApproximatedInWords(Duration duration) {
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 (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";
}

View File

@ -0,0 +1,124 @@
import 'package:intl/intl.dart';
DateTime getToday() {
DateTime now = DateTime.now().toUtc();
return DateTime.utc(now.year, now.month, now.day);
}
enum DatePrecision { decade, year, month, day, hour, minute }
extension DatePrecisionComparison on DatePrecision {
bool operator <(DatePrecision other) {
return index < other.index;
}
bool operator <=(DatePrecision other) {
return index <= other.index;
}
bool operator >(DatePrecision other) {
return index > other.index;
}
bool operator >=(DatePrecision other) {
return index >= other.index;
}
}
DateTime simplifyDateToPrecision(DateTime date, DatePrecision precision) {
switch (precision) {
case DatePrecision.decade:
return DateTime(date.year ~/ 10 * 10);
case DatePrecision.year:
return DateTime(date.year);
case DatePrecision.month:
return DateTime(date.year, date.month);
case DatePrecision.day:
return DateTime(date.year, date.month, date.day);
case DatePrecision.hour:
return DateTime(date.year, date.month, date.day, date.hour);
case DatePrecision.minute:
return DateTime(date.year, date.month, date.day, date.hour, date.minute);
}
}
class DateWithPrecision implements Comparable<DateWithPrecision> {
DateTime date;
DatePrecision precision;
DateWithPrecision(DateTime date, this.precision)
: date = simplifyDateToPrecision(date, precision);
DateWithPrecision.fromJsonEncodable(List<dynamic> json)
: date = DateTime.parse(json[0]),
precision = DatePrecision.values
.firstWhere((element) => element.name == json[1]);
DateWithPrecision.today() : this(DateTime.now().toUtc(), DatePrecision.day);
List<dynamic> toJsonEncodable() {
return [date.toIso8601String(), precision.name];
}
@override
String toString() {
return switch (precision) {
DatePrecision.decade =>
"${DateFormat("yyyy").format(date).substring(0, 3)}0s",
DatePrecision.year => DateFormat.y().format(date),
DatePrecision.month => DateFormat.yMMMM().format(date),
DatePrecision.day => DateFormat.yMMMMd().format(date),
DatePrecision.hour => DateFormat("MMMM d, yyyy, HH").format(date),
DatePrecision.minute => DateFormat("MMMM d, yyyy, HH:mm").format(date)
};
}
@override
int compareTo(DateWithPrecision other) {
if (date.isBefore(other.date)) {
return -1;
} else if (date.isAfter(other.date)) {
return 1;
} else {
return precision.index - other.precision.index;
}
}
@override
bool operator ==(Object other) {
return other is DateWithPrecision &&
date == other.date &&
precision == other.precision;
}
@override
int get hashCode {
return date.hashCode ^ precision.hashCode;
}
bool includes(DateTime date) {
switch (precision) {
case DatePrecision.decade:
return this.date.year ~/ 10 == date.year ~/ 10;
case DatePrecision.year:
return this.date.year == date.year;
case DatePrecision.month:
return this.date.year == date.year && this.date.month == date.month;
case DatePrecision.day:
return this.date.year == date.year &&
this.date.month == date.month &&
this.date.day == date.day;
case DatePrecision.hour:
return this.date.year == date.year &&
this.date.month == date.month &&
this.date.day == date.day &&
this.date.hour == date.hour;
case DatePrecision.minute:
return this.date.year == date.year &&
this.date.month == date.month &&
this.date.day == date.day &&
this.date.hour == date.hour &&
this.date.minute == date.minute;
}
}
}

View File

@ -1,21 +1,26 @@
import 'dart:async'; import 'dart:async';
class DelayedFunctionCaller { class DelayedFunctionCaller {
final Function function; final void Function() function;
final Duration duration; final Duration duration;
final bool resetTimerOnCall;
Timer? _timer; Timer? _timer;
DelayedFunctionCaller(this.function, this.duration); DelayedFunctionCaller(this.function, this.duration,
{this.resetTimerOnCall = false});
get scheduled => _timer != null && _timer!.isActive;
void call() { void call() {
// If a timer is already active, return.
if (_timer != null && _timer!.isActive) { if (_timer != null && _timer!.isActive) {
// If a timer is already active and we don't want to reset it, return.
if (!resetTimerOnCall) {
return; return;
} }
_timer!.cancel();
}
// Create a timer that calls the function after the specified duration. // Create a timer that calls the function after the specified duration.
_timer = Timer(duration, () { _timer = Timer(duration, function);
function();
});
} }
} }

View File

@ -0,0 +1,60 @@
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 = [];
late final DelayedFunctionCaller _searchCaller;
final MovieManager manager;
bool searchingOnline = false;
LiveSearch(this.manager) {
_searchCaller = DelayedFunctionCaller(
searchOnline,
const Duration(milliseconds: 750),
resetTimerOnCall: true,
);
}
get loading => searchingOnline || _searchCaller.scheduled;
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();
notifyListeners();
return;
}
searchingOnline = true;
try {
String startedSearching = searchTerm;
List<MovieData> onlineResults = await manager.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;
notifyListeners();
}
}
}

View File

@ -1,18 +1,25 @@
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import 'package:release_schedule/model/movie.dart'; import 'package:release_schedule/model/movie.dart';
class LocalMovieStorage { abstract class LocalMovieStorage {
void update(List<MovieData> movies);
Future<List<MovieData>> retrieve();
}
class InMemoryMovieStorage implements LocalMovieStorage {
List<MovieData> _storedMovies = []; List<MovieData> _storedMovies = [];
@override
update(List<MovieData> movies) { update(List<MovieData> movies) {
_storedMovies = movies; _storedMovies = movies;
} }
@override
Future<List<MovieData>> retrieve() async { Future<List<MovieData>> retrieve() async {
return _storedMovies; return _storedMovies;
} }
} }
class LocalMovieStorageGetStorage extends LocalMovieStorage { class LocalMovieStorageGetStorage implements LocalMovieStorage {
Future<void>? initialized; Future<void>? initialized;
GetStorage? container; GetStorage? container;
MovieData Function(Map jsonEncodable) toMovieData; MovieData Function(Map jsonEncodable) toMovieData;

View File

@ -1,15 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:release_schedule/model/dates.dart';
class MovieData extends ChangeNotifier { class MovieData extends ChangeNotifier {
String _title; String _title;
DateWithPrecisionAndCountry _releaseDate; DateWithPrecisionAndCountry _releaseDate;
bool _bookmarked = false;
bool _hasDetails = false; bool _hasDetails = false;
List<DateWithPrecisionAndCountry>? _releaseDates; List<DateWithPrecisionAndCountry>? _releaseDates;
List<String>? _genres; List<String>? _genres;
List<TitleInLanguage>? _titles; List<TitleInLanguage>? _titles;
List<Review>? _reviews;
MovieData(this._title, this._releaseDate); MovieData(this._title, this._releaseDate);
@ -21,6 +21,10 @@ class MovieData extends ChangeNotifier {
return _releaseDate; return _releaseDate;
} }
bool get bookmarked {
return _bookmarked;
}
List<DateWithPrecisionAndCountry>? get releaseDates { List<DateWithPrecisionAndCountry>? get releaseDates {
return _releaseDates; return _releaseDates;
} }
@ -33,37 +37,37 @@ class MovieData extends ChangeNotifier {
return _titles; return _titles;
} }
List<Review>? get reviews {
return _reviews;
}
bool get hasDetails { bool get hasDetails {
return _hasDetails; return _hasDetails;
} }
void updateWithNew(MovieData movie) { /// Updates the information with that of a new version of the movie
/// but ignores fields that are user controlled, like whether the movie was bookmarked.
void updateWithNewIgnoringUserControlled(MovieData movie) {
setDetails( setDetails(
title: movie.title, title: movie.title,
releaseDate: movie.releaseDate, releaseDate: movie.releaseDate,
releaseDates: movie.releaseDates, releaseDates: movie.releaseDates,
genres: movie.genres, genres: movie.genres,
titles: movie.titles, titles: movie.titles);
reviews: movie.reviews);
} }
void setDetails( void setDetails(
{String? title, {String? title,
DateWithPrecisionAndCountry? releaseDate, DateWithPrecisionAndCountry? releaseDate,
bool? bookmarked,
List<DateWithPrecisionAndCountry>? releaseDates, List<DateWithPrecisionAndCountry>? releaseDates,
List<String>? genres, List<String>? genres,
List<TitleInLanguage>? titles, List<TitleInLanguage>? titles}) {
List<Review>? reviews}) {
if (title != null) { if (title != null) {
_title = title; _title = title;
} }
if (releaseDate != null) { if (releaseDate != null) {
_releaseDate = releaseDate; _releaseDate = releaseDate;
} }
if (bookmarked != null) {
_bookmarked = bookmarked;
}
if (releaseDates != null) { if (releaseDates != null) {
_releaseDates = releaseDates; _releaseDates = releaseDates;
} }
@ -73,16 +77,18 @@ class MovieData extends ChangeNotifier {
if (titles != null) { if (titles != null) {
_titles = titles; _titles = titles;
} }
if (reviews != null) {
_reviews = reviews;
}
_hasDetails = true; _hasDetails = true;
notifyListeners(); notifyListeners();
} }
@override @override
String toString() { String toString() {
return "$title (${_releaseDate.toString()}${_genres?.isNotEmpty ?? true ? "; ${_genres?.join(", ")}" : ""})"; return "$title (${_releaseDate.toString()}${_genres?.isNotEmpty ?? false ? "; ${_genres?.join(", ")}" : ""})";
}
bool same(MovieData other) {
return title == other.title &&
releaseDate.dateWithPrecision == other.releaseDate.dateWithPrecision;
} }
Map toJsonEncodable() { Map toJsonEncodable() {
@ -92,22 +98,19 @@ class MovieData extends ChangeNotifier {
return { return {
"title": title, "title": title,
"releaseDate": _releaseDate.toJsonEncodable(), "releaseDate": _releaseDate.toJsonEncodable(),
"bookmarked": _bookmarked,
"releaseDates": releaseDatesByCountry, "releaseDates": releaseDatesByCountry,
"genres": genres, "genres": genres,
"titles": titlesByCountry, "titles": titlesByCountry,
"reviews": reviews?.map((review) => review.toJsonEncodable()).toList(),
}; };
} }
bool same(MovieData other) {
return title == other.title && releaseDate.date == other.releaseDate.date;
}
MovieData.fromJsonEncodable(Map json) MovieData.fromJsonEncodable(Map json)
: _title = json["title"], : _title = json["title"],
_releaseDate = _releaseDate =
DateWithPrecisionAndCountry.fromJsonEncodable(json["releaseDate"]) { DateWithPrecisionAndCountry.fromJsonEncodable(json["releaseDate"]) {
setDetails( setDetails(
bookmarked: json["bookmarked"] as bool,
genres: (json["genres"] as List<dynamic>?) genres: (json["genres"] as List<dynamic>?)
?.map((genre) => genre as String) ?.map((genre) => genre as String)
.toList(), .toList(),
@ -117,11 +120,6 @@ class MovieData extends ChangeNotifier {
DateWithPrecisionAndCountry.fromJsonEncodable(release)) DateWithPrecisionAndCountry.fromJsonEncodable(release))
.toList() .toList()
: null, : null,
reviews: json["reviews"] != null
? (json["reviews"] as List<dynamic>)
.map((review) => Review.fromJsonEncodable(review))
.toList()
: null,
titles: json["titles"] != null titles: json["titles"] != null
? (json["titles"] as List<dynamic>) ? (json["titles"] as List<dynamic>)
.map((title) => .map((title) =>
@ -131,61 +129,26 @@ class MovieData extends ChangeNotifier {
} }
} }
enum DatePrecision { decade, year, month, day, hour, minute }
typedef TitleInLanguage = ({String title, String language}); typedef TitleInLanguage = ({String title, String language});
class DateWithPrecisionAndCountry { class DateWithPrecisionAndCountry {
DateTime date; final DateWithPrecision dateWithPrecision;
DatePrecision precision; final String country;
String country;
DateWithPrecisionAndCountry(this.date, this.precision, this.country); DateWithPrecisionAndCountry(
DateTime date, DatePrecision precision, this.country)
: dateWithPrecision = DateWithPrecision(date, precision);
DateWithPrecisionAndCountry.fromJsonEncodable(List<dynamic> json) DateWithPrecisionAndCountry.fromJsonEncodable(List<dynamic> json)
: date = DateTime.parse(json[0]), : dateWithPrecision = DateWithPrecision.fromJsonEncodable(json),
precision = DatePrecision.values
.firstWhere((element) => element.name == json[1]),
country = json[2]; country = json[2];
toJsonEncodable() { toJsonEncodable() {
return [date.toIso8601String(), precision.name, country]; return dateWithPrecision.toJsonEncodable() + [country];
} }
@override @override
String toString() { String toString() {
String dateString = switch (precision) { return "${dateWithPrecision.toString()} ($country)";
DatePrecision.decade =>
"${DateFormat("yyyy").format(date).substring(0, 3)}0s",
DatePrecision.year => date.year.toString(),
DatePrecision.month => DateFormat("MMMM yyyy").format(date),
DatePrecision.day => DateFormat("MMMM d, yyyy").format(date),
DatePrecision.hour => DateFormat("MMMM d, yyyy, HH").format(date),
DatePrecision.minute => DateFormat("MMMM d, yyyy, HH:mm").format(date)
};
return "$dateString ($country)";
}
}
class Review {
String score;
String by;
DateTime asOf;
int count;
Review(this.score, this.by, this.asOf, this.count);
Review.fromJsonEncodable(Map json)
: score = json["score"],
by = json["by"],
asOf = DateTime.parse(json["asOf"]),
count = json["count"];
Map toJsonEncodable() {
return {
"score": score,
"by": by,
"asOf": asOf.toIso8601String(),
"count": count,
};
} }
} }

View File

@ -2,20 +2,17 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:release_schedule/api/movie_api.dart'; import 'package:release_schedule/api/movie_api.dart';
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(),
LocalMovieStorageGetStorage(WikidataMovieData.fromEncodable));
class MovieManager extends ChangeNotifier { class MovieManager extends ChangeNotifier {
final List<MovieData> movies = List.empty(growable: true); final List<MovieData> movies = List.empty(growable: true);
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 +28,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
@ -44,7 +41,7 @@ class MovieManager extends ChangeNotifier {
bool added = false; bool added = false;
for (var movie in additionalMovies) { for (var movie in additionalMovies) {
MovieData? existing = MovieData? existing =
firstWhereOrNull(movies, (element) => movie.same(element)); movies.where((element) => movie.same(element)).firstOrNull;
if (existing == null) { if (existing == null) {
_insertMovie(movie); _insertMovie(movie);
movie.addListener(() { movie.addListener(() {
@ -54,7 +51,7 @@ class MovieManager extends ChangeNotifier {
added = true; added = true;
actualMovies.add(movie); actualMovies.add(movie);
} else { } else {
existing.updateWithNew(movie); existing.updateWithNewIgnoringUserControlled(movie);
actualMovies.add(existing); actualMovies.add(existing);
} }
} }
@ -67,10 +64,10 @@ class MovieManager extends ChangeNotifier {
void _insertMovie(MovieData movie) { void _insertMovie(MovieData movie) {
int min = 0; int min = 0;
int max = movies.length - 1; int max = movies.length - 1;
while (min - 1 < max) { while (min <= max) {
int center = ((min + max) / 2).floor(); int center = (min + max) ~/ 2;
int diff = int diff = movie.releaseDate.dateWithPrecision
movie.releaseDate.date.compareTo(movies[center].releaseDate.date); .compareTo(movies[center].releaseDate.dateWithPrecision);
if (diff < 0) { if (diff < 0) {
max = center - 1; max = center - 1;
} else { } else {
@ -85,7 +82,12 @@ class MovieManager extends ChangeNotifier {
var temp = movies[i]; var temp = movies[i];
int j = i - 1; int j = i - 1;
for (; for (;
j >= 0 && movies[j].releaseDate.date.isAfter(temp.releaseDate.date); j >= 0 &&
movies[j]
.releaseDate
.dateWithPrecision
.compareTo(temp.releaseDate.dateWithPrecision) >
0;
j--) { j--) {
movies[j + 1] = movies[j]; movies[j + 1] = movies[j];
} }
@ -108,16 +110,21 @@ 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(
/// Online search for movies. movies,
Future<List<MovieData>> search(String search) async { search,
List<MovieData> movies = await api.searchForMovies(search); (movie) => [
return addMovies(movies); movie.title,
...(movie.titles?.map((title) => title.title) ?? []),
]);
return results;
} }
void expandDetails(List<MovieData> movies) { /// Online search for movies.
api.addMovieDetails(movies); Future<List<MovieData>> onlineSearch(String search) async {
List<MovieData> movies = await api.searchForMovies(search);
return addMovies(movies);
} }
Future<void> loadUpcomingMovies() async { Future<void> loadUpcomingMovies() async {
@ -133,11 +140,3 @@ class MovieManager extends ChangeNotifier {
} }
} }
} }
T? firstWhereOrNull<T>(List<T> list, bool Function(T element) test) {
try {
return list.firstWhere(test);
} catch (e) {
return null;
}
}

View File

@ -0,0 +1,94 @@
import 'dart:math';
class Scored<T> {
T data;
double score;
Scored(this.data, this.score);
@override
toString() => '$data: $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

@ -1,10 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:release_schedule/model/date_format.dart';
import 'package:release_schedule/model/movie.dart'; import 'package:release_schedule/model/movie.dart';
import 'package:release_schedule/view/movie_page.dart';
class MovieItem extends StatelessWidget { class MovieItem extends StatelessWidget {
final MovieData movie; final MovieData movie;
const MovieItem(this.movie, {super.key}); final bool showReleaseDate;
const MovieItem(this.movie, {this.showReleaseDate = false, super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -14,7 +15,26 @@ class MovieItem extends StatelessWidget {
return ListTile( return ListTile(
title: Text(movie.title), title: Text(movie.title),
subtitle: Text( subtitle: Text(
"${dateRelativeToNow(movie.releaseDate.date)}, ${movie.releaseDate.toString()}, ${movie.genres?.join(", ") ?? ""}")); (showReleaseDate ? "${movie.releaseDate} " : "") +
(movie.genres?.join(", ") ?? ""),
),
trailing: IconButton(
icon: Icon(movie.bookmarked
? Icons.bookmark_added
: Icons.bookmark_border),
onPressed: () => movie.setDetails(bookmarked: !movie.bookmarked),
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return MoviePage(movie);
},
),
);
},
);
}, },
); );
} }

View File

@ -1,18 +1,186 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:release_schedule/model/dates.dart';
import 'package:release_schedule/model/movie.dart'; import 'package:release_schedule/model/movie.dart';
import 'package:release_schedule/view/movie_item.dart'; import 'package:release_schedule/view/movie_item.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class MovieList extends StatelessWidget { class MovieList extends StatelessWidget {
final List<MovieData> movies; final List<MovieData> movies;
const MovieList(this.movies, {super.key}); final bool Function(MovieData)? filter;
const MovieList(this.movies, {this.filter, super.key});
@override @override
Widget build(Object context) { Widget build(BuildContext context) {
return ListView.builder( Widget noMovies() {
return Center(
child: IntrinsicHeight(
child: Column(
children: [
const Icon(
Icons.close,
size: 100,
),
Text(
"No Movies",
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
);
}
if (movies.isEmpty) {
return noMovies();
}
Widget buildGroupSeparator(BuildContext context, DateWithPrecision date) {
bool highlight = date.includes(DateTime.now());
return SizedBox(
height: 50,
child: Align(
alignment: Alignment.center,
child: Card(
elevation: 3,
color: highlight
? Theme.of(context).colorScheme.primaryContainer
: null,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Text(
date.toString(),
),
),
),
),
);
}
final localFilter = filter;
if (localFilter != null) {
List<int> indexMap = [];
int index = 0;
for (var movie in movies) {
if (localFilter(movie)) {
indexMap.add(index);
}
index++;
}
if (indexMap.isEmpty) {
return noMovies();
}
int firstMovieTodayOrAfterIndex = () {
DateWithPrecision today = DateWithPrecision.today();
int min = 0;
int max = indexMap.length;
while (min < max) {
int center = (min + max) ~/ 2;
DateWithPrecision date =
movies[indexMap[center]].releaseDate.dateWithPrecision;
if (date.compareTo(today) < 0) {
min = center + 1;
} else {
max = center;
}
}
return max;
}();
return GroupedList<DateWithPrecision>(
itemCount: indexMap.length,
groupBy: (index) =>
movies[indexMap[index]].releaseDate.dateWithPrecision,
groupSeparatorBuilder: (date) => buildGroupSeparator(context, date),
itemBuilder: (context, index) {
return MovieItem(movies[indexMap[index]]);
},
initialScrollIndex: firstMovieTodayOrAfterIndex,
);
}
int firstMovieTodayOrAfterIndex = () {
DateWithPrecision today = DateWithPrecision.today();
int min = 0;
int max = movies.length;
while (min < max) {
int center = (min + max) ~/ 2;
DateWithPrecision date = movies[center].releaseDate.dateWithPrecision;
if (date.compareTo(today) < 0) {
min = center + 1;
} else {
max = center;
}
}
return max;
}();
return GroupedList<DateWithPrecision>(
itemCount: movies.length, itemCount: movies.length,
groupBy: (index) => movies[index].releaseDate.dateWithPrecision,
groupSeparatorBuilder: (date) => buildGroupSeparator(context, date),
itemBuilder: (context, index) { itemBuilder: (context, index) {
return MovieItem(movies[index]); return MovieItem(movies[index]);
}, },
initialScrollIndex: firstMovieTodayOrAfterIndex,
);
}
}
class GroupedList<GroupType> extends StatelessWidget {
final int itemCount;
final int initialScrollIndex;
final Widget Function(BuildContext, int) itemBuilder;
final Widget Function(GroupType) groupSeparatorBuilder;
final GroupType Function(int) groupBy;
const GroupedList(
{required this.itemCount,
required this.itemBuilder,
required this.groupSeparatorBuilder,
required this.groupBy,
this.initialScrollIndex = 0,
super.key});
@override
Widget build(BuildContext context) {
if (itemCount == 0) {
return Container();
}
List<({int index, GroupType group})> newGroupStarts = [
(index: 0, group: groupBy(0))
];
int internalInitialScrollIndex =
initialScrollIndex + (initialScrollIndex > 0 ? 1 : 0);
GroupType last = newGroupStarts[0].group;
for (int i = 1; i < itemCount; i++) {
final GroupType current = groupBy(i);
if (current != last) {
newGroupStarts.add((index: i, group: current));
if (initialScrollIndex > i) {
internalInitialScrollIndex++;
}
}
last = current;
}
Widget itemAndSeparatorBuilder(BuildContext context, int index) {
int itemIndex = index;
for (int i = 0; i < newGroupStarts.length; i++) {
if (newGroupStarts[i].index > itemIndex) {
break;
} else if (newGroupStarts[i].index == itemIndex) {
return groupSeparatorBuilder(groupBy(itemIndex));
}
itemIndex--;
}
return itemBuilder(context, itemIndex);
}
return ScrollablePositionedList.builder(
itemCount: itemCount + newGroupStarts.length,
itemBuilder: itemAndSeparatorBuilder,
initialScrollIndex: internalInitialScrollIndex,
); );
} }
} }

View File

@ -1,10 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.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_list.dart';
class MovieManagerList extends StatelessWidget { class MovieManagerList extends StatelessWidget {
final MovieManager manager; final MovieManager manager;
const MovieManagerList(this.manager, {super.key}); final bool Function(MovieData)? filter;
const MovieManagerList(this.manager, {this.filter, super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -14,7 +16,7 @@ class MovieManagerList extends StatelessWidget {
return Column( return Column(
children: [ children: [
manager.loading ? const LinearProgressIndicator() : Container(), manager.loading ? const LinearProgressIndicator() : Container(),
Expanded(child: MovieList(manager.movies)) Expanded(child: MovieList(manager.movies, filter: filter))
], ],
); );
}, },

View File

@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:release_schedule/model/movie.dart';
class Heading extends StatelessWidget {
final String text;
const Heading(this.text, {super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 20, bottom: 10),
child: Text(
text,
style: Theme.of(context).textTheme.headlineSmall,
),
);
}
}
class MoviePage extends StatelessWidget {
final MovieData movie;
const MoviePage(this.movie, {super.key});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: movie,
builder: (context, child) {
return Scaffold(
appBar: AppBar(title: Text(movie.title), actions: [
IconButton(
icon: Icon(movie.bookmarked
? Icons.bookmark_added
: Icons.bookmark_outline),
onPressed: () => movie.setDetails(bookmarked: !movie.bookmarked),
),
]),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 10,
runSpacing: 10,
children: movie.genres
?.map((genre) => Chip(label: Text(genre)))
.toList() ??
[],
),
const SizedBox(height: 20),
const Heading("Titles"),
Table(
border: TableBorder.symmetric(
inside: BorderSide(
color: Theme.of(context).dividerColor,
),
),
children: movie.titles?.map((title) {
return TableRow(
children: [
TableCell(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(title.language),
)),
TableCell(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(title.title),
))
],
);
}).toList() ??
[],
),
const Heading("Release Dates"),
Table(
border: TableBorder.symmetric(
inside: BorderSide(
color: Theme.of(context).dividerColor,
),
),
children: movie.releaseDates?.map((releaseDate) {
return TableRow(
children: [
TableCell(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(releaseDate.country),
)),
TableCell(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
releaseDate.dateWithPrecision.toString(),
),
))
],
);
}).toList() ??
[],
),
],
),
),
),
);
},
);
}
}

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

@ -219,6 +219,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.6" version: "2.1.6"
scrollable_positioned_list:
dependency: "direct main"
description:
name: scrollable_positioned_list
sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287"
url: "https://pub.dev"
source: hosted
version: "0.3.8"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter

View File

@ -14,6 +14,7 @@ dependencies:
http: ^1.1.0 http: ^1.1.0
intl: ^0.18.1 intl: ^0.18.1
get_storage: ^2.1.1 get_storage: ^2.1.1
scrollable_positioned_list: ^0.3.8
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@ -1,84 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:release_schedule/model/date_format.dart';
void main() {
group('dateRelativeToNow', () {
test('returns "Today" for today\'s date', () {
final today = DateTime.now();
final result = dateRelativeToNow(today);
expect(result, 'Today');
});
test('returns "Tomorrow" for tomorrow\'s date', () {
final tomorrow = DateTime.now().add(const Duration(days: 1));
final result = dateRelativeToNow(tomorrow);
expect(result, 'Tomorrow');
});
test('returns "Yesterday" for yesterday\'s date', () {
final yesterday = DateTime.now().subtract(const Duration(days: 1));
final result = dateRelativeToNow(yesterday);
expect(result, 'Yesterday');
});
test('returns "In 5 days" for a date 5 days in the future', () {
final futureDate = DateTime.now().add(const Duration(days: 5));
final result = dateRelativeToNow(futureDate);
expect(result, 'In 5 days');
});
test('returns "5 days ago" for a date 5 days in the past', () {
final pastDate = DateTime.now().subtract(const Duration(days: 5));
final result = dateRelativeToNow(pastDate);
expect(result, '5 days ago');
});
test('returns "a week" for a date 7 days in the future', () {
final futureDate = DateTime.now().add(const Duration(days: 7));
final result = dateRelativeToNow(futureDate);
expect(result, 'In a week');
});
test('returns "a week" for a date 7 days in the past', () {
final pastDate = DateTime.now().subtract(const Duration(days: 7));
final result = dateRelativeToNow(pastDate);
expect(result, 'A week ago');
});
test('returns "a month" for a date 30 days in the future', () {
final futureDate = DateTime.now().add(const Duration(days: 30));
final result = dateRelativeToNow(futureDate);
expect(result, 'In a month');
});
test('returns "a month" for a date 30 days in the past', () {
final pastDate = DateTime.now().subtract(const Duration(days: 30));
final result = dateRelativeToNow(pastDate);
expect(result, 'A month ago');
});
test('returns "a year" for a date 365 days in the future', () {
final futureDate = DateTime.now().add(const Duration(days: 365));
final result = dateRelativeToNow(futureDate);
expect(result, 'In a year');
});
test('returns "a year" for a date 365 days in the past', () {
final pastDate = DateTime.now().subtract(const Duration(days: 365));
final result = dateRelativeToNow(pastDate);
expect(result, 'A year ago');
});
test('returns "a century" for a date 100 years in the future', () {
final futureDate = DateTime.now().add(const Duration(days: 365 * 100));
final result = dateRelativeToNow(futureDate);
expect(result, 'In a century');
});
test('returns "a century" for a date 100 years in the past', () {
final pastDate = DateTime.now().subtract(const Duration(days: 365 * 100));
final result = dateRelativeToNow(pastDate);
expect(result, 'A century ago');
});
});
}

View File

@ -0,0 +1,114 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:release_schedule/model/dates.dart';
void main() {
group("DatePrecisionComparison", () {
test("can compare with inequality", () {
expect(DatePrecision.decade < DatePrecision.year, isTrue);
expect(DatePrecision.year <= DatePrecision.year, isTrue);
expect(DatePrecision.month > DatePrecision.day, isFalse);
expect(DatePrecision.day > DatePrecision.day, isFalse);
expect(DatePrecision.hour >= DatePrecision.month, isTrue);
});
test("can compare with equality", () {
expect(DatePrecision.decade == DatePrecision.decade, isTrue);
expect(DatePrecision.year != DatePrecision.decade, isTrue);
});
});
test("simplifyDatesToPrecision", () {
expect(simplifyDateToPrecision(DateTime(2024, 5, 14), DatePrecision.decade),
equals(DateTime(2020, 1, 1)));
expect(simplifyDateToPrecision(DateTime(2024, 5, 14), DatePrecision.year),
equals(DateTime(2024, 1, 1)));
expect(simplifyDateToPrecision(DateTime(2024, 5, 14), DatePrecision.month),
equals(DateTime(2024, 5, 1)));
expect(
simplifyDateToPrecision(
DateTime(2024, 5, 14, 10, 42), DatePrecision.day),
equals(DateTime(2024, 5, 14)));
expect(
simplifyDateToPrecision(
DateTime(2024, 5, 14, 10, 42), DatePrecision.hour),
equals(DateTime(2024, 5, 14, 10)));
expect(
simplifyDateToPrecision(
DateTime(2024, 5, 14, 10, 42, 12), DatePrecision.minute),
equals(DateTime(2024, 5, 14, 10, 42)));
});
group("DateWithPrecision", () {
test("includes", () {
DateTime originalDate = DateTime(2024, 5, 14, 15, 42, 12);
expect(
DateWithPrecision(originalDate, DatePrecision.minute)
.includes(DateTime(2024, 5, 14, 15, 42, 12)),
isTrue);
expect(
DateWithPrecision(originalDate, DatePrecision.minute)
.includes(DateTime(2024, 5, 14, 15, 43, 1)),
isFalse);
expect(
DateWithPrecision(originalDate, DatePrecision.hour)
.includes(DateTime(2024, 5, 14, 15, 42, 12)),
isTrue);
expect(
DateWithPrecision(originalDate, DatePrecision.hour)
.includes(DateTime(2024, 5, 14, 16, 10, 12)),
isFalse);
expect(
DateWithPrecision(originalDate, DatePrecision.day)
.includes(DateTime(2024, 5, 14)),
isTrue);
expect(
DateWithPrecision(originalDate, DatePrecision.day)
.includes(DateTime(2024, 5, 15)),
isFalse);
expect(
DateWithPrecision(originalDate, DatePrecision.month)
.includes(DateTime(2024, 5, 20)),
isTrue);
expect(
DateWithPrecision(originalDate, DatePrecision.month)
.includes(DateTime(2024, 6, 10)),
isFalse);
expect(
DateWithPrecision(originalDate, DatePrecision.year)
.includes(DateTime(2024, 12, 31)),
isTrue);
expect(
DateWithPrecision(originalDate, DatePrecision.year)
.includes(DateTime(2025, 1, 1)),
isFalse);
expect(
DateWithPrecision(originalDate, DatePrecision.decade)
.includes(DateTime(2029, 12, 31)),
isTrue);
expect(
DateWithPrecision(originalDate, DatePrecision.decade)
.includes(DateTime(2020, 1, 1)),
isTrue);
expect(
DateWithPrecision(originalDate, DatePrecision.decade)
.includes(DateTime(2030, 1, 1)),
isFalse);
});
test("toString", () {
DateTime date = DateTime(2024, 5, 14, 15, 42, 12);
expect(DateWithPrecision(date, DatePrecision.minute).toString(),
equals("May 14, 2024, 15:42"));
expect(DateWithPrecision(date, DatePrecision.hour).toString(),
equals("May 14, 2024, 15"));
expect(DateWithPrecision(date, DatePrecision.day).toString(),
equals("May 14, 2024"));
expect(DateWithPrecision(date, DatePrecision.month).toString(),
equals("May 2024"));
expect(DateWithPrecision(date, DatePrecision.year).toString(),
equals("2024"));
expect(DateWithPrecision(date, DatePrecision.decade).toString(),
equals("2020s"));
});
});
}

View File

@ -0,0 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:release_schedule/api/json_helper.dart';
void main() {
group("selectInJson", () {
late Map<String, dynamic> json;
setUp(() {
json = {
"a": {
"b": [
{"c": 1},
{"c": 2},
{"c": 3},
],
"c": 4,
},
"d": [
{"e": 5},
{"e": 6},
{"e": "7"},
{"e": 7},
]
};
});
test("should select a value", () {
expect(selectInJson<int>(json, "a.b.1.c").toList(), equals([2]));
});
test("should select multiple values", () {
expect(selectInJson<int>(json, "a.b.*.c").toList(), equals([1, 2, 3]));
});
test("should select multiple values with nested lists", () {
expect(selectInJson<int>(json, "a.**.c").toList(), equals([4, 1, 2, 3]));
});
test("should select multiple values with nested lists and maps", () {
expect(selectInJson<int>(json, "**.e").toList(), equals([5, 6, 7]));
});
});
}

View File

@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:release_schedule/api/movie_api.dart'; import 'package:release_schedule/api/movie_api.dart';
import 'package:release_schedule/model/dates.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/movie_manager.dart'; import 'package:release_schedule/model/movie_manager.dart';
@ -11,7 +12,7 @@ void main() {
setUp(() { setUp(() {
movieManager = MovieManager( movieManager = MovieManager(
MovieApi(), MovieApi(),
LocalMovieStorage(), InMemoryMovieStorage(),
); );
}); });
@ -63,7 +64,38 @@ void main() {
expect(movieManager.movies, equals([...movies, ...newMovies])); expect(movieManager.movies, equals([...movies, ...newMovies]));
}); });
test("addMovies should sort movies by their release dates", () { test('addMovies should update existing movies', () {
final movies = [
MovieData(
'The Matrix',
DateWithPrecisionAndCountry(DateTime(1999, 3, 31), DatePrecision.day,
'United States of America'),
),
MovieData(
'The Matrix Reloaded',
DateWithPrecisionAndCountry(DateTime(2003, 5, 7), DatePrecision.day,
'United States of America'),
),
];
movieManager.addMovies(movies);
final updatedMovie = MovieData(
'The Matrix Reloaded',
DateWithPrecisionAndCountry(DateTime(2003, 5, 7), DatePrecision.day,
'United States of America'),
)..setDetails(
bookmarked: true,
genres: ['Action', 'Adventure'],
);
movieManager.addMovies([updatedMovie]);
expect(movieManager.movies[1].genres, equals(updatedMovie.genres));
expect(movieManager.movies[1].bookmarked, equals(false));
});
test('addMovies should sort movies by their release dates', () {
final movies = [ final movies = [
MovieData( MovieData(
'The Matrix Reloaded', 'The Matrix Reloaded',
@ -82,6 +114,27 @@ void main() {
expect(movieManager.movies, equals([...movies.reversed])); expect(movieManager.movies, equals([...movies.reversed]));
}); });
test(
'addMovies should sort movies that have a less precise release date before movies with more precise release dates',
() {
final movies = [
MovieData(
'The Matrix Reloaded',
DateWithPrecisionAndCountry(DateTime(2003, 5, 7), DatePrecision.day,
'United States of America'),
),
MovieData(
'The Matrix',
DateWithPrecisionAndCountry(DateTime(2003, 5, 7), DatePrecision.month,
'United States of America'),
),
];
movieManager.addMovies(movies);
expect(movieManager.movies, equals([...movies.reversed]));
});
test( test(
'when a movie is modified and it\'s date is changed the movies should be resorted', 'when a movie is modified and it\'s date is changed the movies should be resorted',
() async { () async {
@ -135,5 +188,28 @@ void main() {
expect(movieManager.movies, equals([notRemoved])); expect(movieManager.movies, equals([notRemoved]));
}); });
test("localSearch", () {
final movies = [
MovieData(
'The Matrix',
DateWithPrecisionAndCountry(DateTime(1999, 3, 31), DatePrecision.day,
'United States of America'),
),
MovieData(
'The Matrix Reloaded',
DateWithPrecisionAndCountry(DateTime(2003, 5, 7), DatePrecision.day,
'United States of America'),
),
];
movieManager.addMovies(movies);
expect(movieManager.localSearch('Matrix'), equals(movies));
expect(movieManager.localSearch('Matrix Re'),
equals(movies.reversed.toList()));
expect(movieManager.localSearch('Matrix Reloaded'), equals([movies[1]]));
expect(movieManager.localSearch('Matrix Revolutions'), equals([]));
});
}); });
} }

View File

@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:release_schedule/model/dates.dart';
import 'package:release_schedule/model/movie.dart'; import 'package:release_schedule/model/movie.dart';
void main() { void main() {
@ -20,10 +21,8 @@ void main() {
'Adventure' 'Adventure'
], titles: [ ], titles: [
(title: 'Title 2', language: 'en') (title: 'Title 2', language: 'en')
], reviews: [
Review('8.5', 'John Doe', DateTime(2023, 1, 1), 100)
]); ]);
movie1.updateWithNew(movie2); movie1.updateWithNewIgnoringUserControlled(movie2);
expect(movie1.title, equals('Title 2')); expect(movie1.title, equals('Title 2'));
expect(movie1.releaseDate.country, equals('UK')); expect(movie1.releaseDate.country, equals('UK'));
expect(movie1.releaseDates!.length, equals(1)); expect(movie1.releaseDates!.length, equals(1));
@ -34,10 +33,6 @@ void main() {
expect(movie1.titles!.length, equals(1)); expect(movie1.titles!.length, equals(1));
expect(movie1.titles![0].title, equals('Title 2')); expect(movie1.titles![0].title, equals('Title 2'));
expect(movie1.titles![0].language, equals('en')); expect(movie1.titles![0].language, equals('en'));
expect(movie1.reviews!.length, equals(1));
expect(movie1.reviews![0].score, equals('8.5'));
expect(movie1.reviews![0].by, equals('John Doe'));
expect(movie1.reviews![0].count, equals(100));
}); });
test('same() returns true for same title and release date', () { test('same() returns true for same title and release date', () {
@ -88,8 +83,6 @@ void main() {
'Adventure' 'Adventure'
], titles: [ ], titles: [
(title: 'Title 2', language: 'en') (title: 'Title 2', language: 'en')
], reviews: [
Review('8.5', 'John Doe', DateTime(2023, 1, 1), 100)
]); ]);
final json = movie.toJsonEncodable(); final json = movie.toJsonEncodable();
final movie2 = MovieData.fromJsonEncodable(json); final movie2 = MovieData.fromJsonEncodable(json);
@ -103,10 +96,6 @@ void main() {
expect(movie2.titles!.length, equals(1)); expect(movie2.titles!.length, equals(1));
expect(movie2.titles![0].title, equals('Title 2')); expect(movie2.titles![0].title, equals('Title 2'));
expect(movie2.titles![0].language, equals('en')); expect(movie2.titles![0].language, equals('en'));
expect(movie2.reviews!.length, equals(1));
expect(movie2.reviews![0].score, equals('8.5'));
expect(movie2.reviews![0].by, equals('John Doe'));
expect(movie2.reviews![0].count, equals(100));
}); });
test('toString()', () { test('toString()', () {
@ -122,8 +111,6 @@ void main() {
'Adventure' 'Adventure'
], titles: [ ], titles: [
(title: 'Title 2', language: 'en') (title: 'Title 2', language: 'en')
], reviews: [
Review('8.5', 'John Doe', DateTime(2023, 1, 1), 100)
]); ]);
expect(movie.toString(), expect(movie.toString(),
equals('Title 1 (January 1, 2023 (US); Action, Adventure)')); equals('Title 1 (January 1, 2023 (US); Action, Adventure)'));
@ -136,8 +123,9 @@ void main() {
DateTime(2023, 1, 1), DatePrecision.day, 'US'); DateTime(2023, 1, 1), DatePrecision.day, 'US');
final json = date.toJsonEncodable(); final json = date.toJsonEncodable();
final date2 = DateWithPrecisionAndCountry.fromJsonEncodable(json); final date2 = DateWithPrecisionAndCountry.fromJsonEncodable(json);
expect(date2.date, equals(date.date)); expect(date2.dateWithPrecision, equals(date.dateWithPrecision));
expect(date2.precision, equals(date.precision)); expect(date2.dateWithPrecision.precision,
equals(date.dateWithPrecision.precision));
expect(date2.country, equals(date.country)); expect(date2.country, equals(date.country));
}); });

View File

@ -2,24 +2,62 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:release_schedule/api/movie_api.dart'; import 'package:release_schedule/api/movie_api.dart';
import 'package:release_schedule/main.dart'; import 'package:release_schedule/main.dart';
import 'package:release_schedule/model/dates.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_manager.dart'; import 'package:release_schedule/model/movie_manager.dart';
import 'package:release_schedule/view/movie_manager_list.dart'; import 'package:release_schedule/view/movie_manager_list.dart';
void main() { void main() {
group('HomePage', () { group('HomePage', () {
testWidgets('displays title', (WidgetTester tester) async { late LocalMovieStorage storage;
MovieManager movieManager = MovieManager(MovieApi(), LocalMovieStorage());
await tester.pumpWidget(MaterialApp(home: HomePage(movieManager)));
expect(find.text('Release Schedule'), findsOneWidget); setUp(() {
storage = InMemoryMovieStorage();
storage.update([
MovieData(
'The Shawshank Redemption',
DateWithPrecisionAndCountry(
DateTime(1994, 9, 22), DatePrecision.day, 'US'),
),
MovieData(
'The Godfather',
DateWithPrecisionAndCountry(
DateTime(1972, 3, 24), DatePrecision.day, 'US'),
),
MovieData(
'The Dark Knight',
DateWithPrecisionAndCountry(
DateTime(2008, 7, 18), DatePrecision.day, 'US'),
),
]);
});
testWidgets('displays search bar', (WidgetTester tester) async {
MovieManager movieManager = MovieManager(MovieApi(), storage);
await tester.pumpWidget(MaterialApp(home: HomePage(movieManager)));
await tester.pump(const Duration(seconds: 3));
expect(find.text('Search'), findsOneWidget);
}); });
testWidgets('displays list of releases', (WidgetTester tester) async { testWidgets('displays list of releases', (WidgetTester tester) async {
MovieManager movieManager = MovieManager(MovieApi(), LocalMovieStorage()); MovieManager movieManager = MovieManager(MovieApi(), storage);
await tester.pumpWidget(MaterialApp(home: HomePage(movieManager))); await tester.pumpWidget(MaterialApp(home: HomePage(movieManager)));
await tester.pump(const Duration(seconds: 3));
expect(find.byType(MovieManagerList), findsOneWidget); expect(find.byType(MovieManagerList), findsOneWidget);
}); });
testWidgets('displays search results', (WidgetTester tester) async {
MovieManager movieManager = MovieManager(MovieApi(), storage);
await tester.pumpWidget(MaterialApp(home: HomePage(movieManager)));
await tester.enterText(
find.byType(TextField), 'The Shawshank Redemption');
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
expect(find.text('The Shawshank Redemption'), findsNWidgets(2));
});
}); });
} }

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:release_schedule/model/dates.dart';
import 'package:release_schedule/model/movie.dart'; import 'package:release_schedule/model/movie.dart';
import 'package:release_schedule/view/movie_item.dart'; import 'package:release_schedule/view/movie_item.dart';
import 'package:release_schedule/view/movie_page.dart';
void main() { void main() {
testWidgets('MovieItem displays movie data', (WidgetTester tester) async { testWidgets('MovieItem displays movie data', (WidgetTester tester) async {
@ -24,9 +26,6 @@ void main() {
expect(find.text('Test Movie'), findsOneWidget); expect(find.text('Test Movie'), findsOneWidget);
final formattedDate = movie.releaseDate.toString();
expect(find.textContaining(formattedDate), findsOneWidget);
expect(find.textContaining('Action, Adventure'), findsOneWidget); expect(find.textContaining('Action, Adventure'), findsOneWidget);
}); });
@ -58,4 +57,88 @@ void main() {
expect(find.textContaining('Action, Adventure, Comedy'), findsOneWidget); expect(find.textContaining('Action, Adventure, Comedy'), findsOneWidget);
}); });
testWidgets('should update when the movie is bookmarked', (tester) async {
final movie = MovieData(
'Test Movie',
DateWithPrecisionAndCountry(
DateTime(2023, 1, 1), DatePrecision.day, 'US'),
);
movie.setDetails(
genres: ['Action', 'Adventure'],
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: MovieItem(movie),
),
),
);
expect(find.byIcon(Icons.bookmark_border), findsOneWidget);
movie.setDetails(
bookmarked: true,
);
await tester.pump();
expect(find.byIcon(Icons.bookmark_added), findsOneWidget);
});
testWidgets("should update the bookmark state when the icon is tapped",
(tester) async {
final movie = MovieData(
'Test Movie',
DateWithPrecisionAndCountry(
DateTime(2023, 1, 1), DatePrecision.day, 'US'),
);
movie.setDetails(
genres: ['Action', 'Adventure'],
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: MovieItem(movie),
),
),
);
expect(find.byIcon(Icons.bookmark_border), findsOneWidget);
await tester.tap(find.byIcon(Icons.bookmark_outline));
await tester.pump();
expect(find.byIcon(Icons.bookmark_added), findsOneWidget);
});
testWidgets("should navigate to MoviePage when tapped", (tester) async {
final movie = MovieData(
'Test Movie',
DateWithPrecisionAndCountry(
DateTime(2023, 1, 1), DatePrecision.day, 'US'),
);
movie.setDetails(
genres: ['Action', 'Adventure'],
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: MovieItem(movie),
),
),
);
expect(find.byIcon(Icons.bookmark_border), findsOneWidget);
await tester.tap(find.byType(ListTile));
await tester.pumpAndSettle();
expect(find.byType(MoviePage), findsOneWidget);
});
} }

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:release_schedule/model/dates.dart';
import 'package:release_schedule/model/movie.dart'; import 'package:release_schedule/model/movie.dart';
import 'package:release_schedule/view/movie_item.dart'; import 'package:release_schedule/view/movie_item.dart';
import 'package:release_schedule/view/movie_list.dart'; import 'package:release_schedule/view/movie_list.dart';
@ -28,7 +29,40 @@ void main() {
), ),
); );
await tester.pumpAndSettle();
expect(find.byType(MovieItem), findsNWidgets(movies.length)); expect(find.byType(MovieItem), findsNWidgets(movies.length));
}); });
testWidgets("should filter the list of movies",
(WidgetTester tester) async {
final movies = [
MovieData(
'The Shawshank Redemption',
DateWithPrecisionAndCountry(
DateTime(1994, 9, 22), DatePrecision.day, 'US'),
),
MovieData(
'The Godfather',
DateWithPrecisionAndCountry(
DateTime(1972, 3, 24), DatePrecision.day, 'US'),
),
];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: MovieList(
movies,
filter: (movie) => movie.title.contains('Godfather'),
),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(MovieItem), findsOneWidget);
});
}); });
} }

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:release_schedule/api/movie_api.dart'; import 'package:release_schedule/api/movie_api.dart';
import 'package:release_schedule/model/dates.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/movie_manager.dart'; import 'package:release_schedule/model/movie_manager.dart';
@ -11,7 +12,7 @@ import 'package:release_schedule/view/movie_manager_list.dart';
void main() { void main() {
group('MovieManagerList', () { group('MovieManagerList', () {
testWidgets('displays movie list', (tester) async { testWidgets('displays movie list', (tester) async {
final manager = MovieManager(MovieApi(), LocalMovieStorage()); final manager = MovieManager(MovieApi(), InMemoryMovieStorage());
manager.addMovies([ manager.addMovies([
MovieData( MovieData(
'Movie 1', 'Movie 1',
@ -33,7 +34,7 @@ void main() {
}); });
testWidgets('updates when new movies are added', (tester) async { testWidgets('updates when new movies are added', (tester) async {
final manager = MovieManager(MovieApi(), LocalMovieStorage()); final manager = MovieManager(MovieApi(), InMemoryMovieStorage());
manager.addMovies([ manager.addMovies([
MovieData( MovieData(
'Movie 1', 'Movie 1',

View File

@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:release_schedule/model/dates.dart';
import 'package:release_schedule/model/movie.dart';
import 'package:release_schedule/view/movie_page.dart';
void main() {
group('MoviePage', () {
testWidgets('should render the movie details', (WidgetTester tester) async {
final movie = MovieData(
'The Shawshank Redemption',
DateWithPrecisionAndCountry(
DateTime(1994, 9, 22), DatePrecision.day, 'US'),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: MoviePage(movie),
),
),
);
await tester.pumpAndSettle();
expect(find.text(movie.title), findsAtLeastNWidgets(1));
});
testWidgets('should bookmark the movie', (WidgetTester tester) async {
final movie = MovieData(
'The Shawshank Redemption',
DateWithPrecisionAndCountry(
DateTime(1994, 9, 22), DatePrecision.day, 'US'),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: MoviePage(movie),
),
),
);
await tester.pumpAndSettle();
expect(movie.bookmarked, isFalse);
await tester.tap(find.byIcon(Icons.bookmark_outline));
await tester.pumpAndSettle();
expect(movie.bookmarked, isTrue);
});
});
testWidgets("should display the movie's genres", (WidgetTester tester) async {
final movie = MovieData(
'The Shawshank Redemption',
DateWithPrecisionAndCountry(
DateTime(1994, 9, 22), DatePrecision.day, 'US'),
)..setDetails(genres: ['Drama']);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: MoviePage(movie),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Drama'), findsOneWidget);
});
testWidgets("should display the movie's titles and release dates",
(WidgetTester tester) async {
final movie = MovieData(
'The Shawshank Redemption',
DateWithPrecisionAndCountry(
DateTime(1994, 9, 22), DatePrecision.day, 'US'),
)..setDetails(
titles: [(title: 'The Shawshank Redemption', language: 'en')],
releaseDates: [
DateWithPrecisionAndCountry(
DateTime(1994, 9, 22), DatePrecision.day, 'US')
],
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: MoviePage(movie),
),
),
);
await tester.pumpAndSettle();
expect(find.text('en'), findsOneWidget);
expect(find.text('The Shawshank Redemption'), findsNWidgets(2));
expect(find.text('US'), findsOneWidget);
expect(find.textContaining('1994'), findsOneWidget);
});
}