Compare commits
17 Commits
f9a0482e3c
...
9aa0278ab0
Author | SHA1 | Date |
---|---|---|
daniel-michel | 9aa0278ab0 | |
daniel-michel | be11dc040c | |
daniel-michel | 57708bc894 | |
daniel-michel | 0a9a8d033f | |
daniel-michel | ed5b537550 | |
daniel-michel | 642f5b70a2 | |
daniel-michel | 6961c744a7 | |
daniel-michel | dfde4d0aea | |
daniel-michel | 698c785896 | |
daniel-michel | 497c2e6d2e | |
daniel-michel | a0e4edb508 | |
daniel-michel | d5861bdb78 | |
daniel-michel | 0caee992ec | |
daniel-michel | f698ebcfbe | |
daniel-michel | 0ea9aef7be | |
daniel-michel | 618f5d135b | |
daniel-michel | 688fa63da2 |
22
README.md
22
README.md
|
@ -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.
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
236
lib/main.dart
236
lib/main.dart
|
@ -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),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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";
|
|
||||||
}
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -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() ??
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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 |
|
@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -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]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -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([]));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in New Issue