Add local caching of movies

main
daniel-michel 2023-11-08 14:43:59 +01:00
parent 91e18b279d
commit a977ad1f34
11 changed files with 362 additions and 45 deletions

View File

@ -1,7 +1,7 @@
import 'package:release_schedule/model/movie.dart'; import 'package:release_schedule/model/movie.dart';
abstract class MovieApi<CustomMovieData extends MovieData> { abstract class MovieApi {
Future<List<CustomMovieData>> getUpcomingMovies([int count]); Future<List<MovieData>> getUpcomingMovies([int count]);
Future<List<CustomMovieData>> searchForMovies(String searchTerm); Future<List<MovieData>> searchForMovies(String searchTerm);
Future<void> addMovieDetails(List<CustomMovieData> movies); Future<void> addMovieDetails(List<MovieData> movies);
} }

View File

@ -10,10 +10,19 @@ class WikidataMovieData extends MovieData {
WikidataMovieData(String title, DateTime releaseDate, this.entityId) WikidataMovieData(String title, DateTime releaseDate, this.entityId)
: super(title, releaseDate); : super(title, releaseDate);
WikidataMovieData.fromEncodable(Map encodable)
: entityId = encodable["entityId"],
super.fromJsonEncodable(encodable);
@override @override
bool same(MovieData other) { bool same(MovieData other) {
return other is WikidataMovieData && entityId == other.entityId; return other is WikidataMovieData && entityId == other.entityId;
} }
@override
Map toJsonEncodable() {
return super.toJsonEncodable()..addAll({"entityId": entityId});
}
} }
String createUpcomingMovieQuery(int limit) { String createUpcomingMovieQuery(int limit) {
@ -35,13 +44,13 @@ ORDER BY ?minReleaseDate
LIMIT $limit"""; LIMIT $limit""";
} }
class WikidataMovieApi implements MovieApi<WikidataMovieData> { class WikidataMovieApi implements MovieApi {
ApiManager searchApi = ApiManager("https://www.wikidata.org/w/api.php"); ApiManager searchApi = ApiManager("https://www.wikidata.org/w/api.php");
ApiManager queryApi = ApiManager queryApi =
ApiManager("https://query.wikidata.org/sparql?format=json"); ApiManager("https://query.wikidata.org/sparql?format=json");
@override @override
Future<void> addMovieDetails(List<WikidataMovieData> movies) { Future<void> addMovieDetails(List<MovieData> movies) {
// TODO: implement addMovieDetails // TODO: implement addMovieDetails
throw UnimplementedError(); throw UnimplementedError();
} }

View File

@ -2,11 +2,10 @@ 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/api/wikidata_movie_api.dart';
import 'package:release_schedule/model/movie_manager.dart'; import 'package:release_schedule/model/movie_manager.dart';
import 'package:release_schedule/view/movie_list.dart'; import 'package:release_schedule/view/movie_manager_list.dart';
void main() { void main() {
runApp(const MyApp()); runApp(const MyApp());
movieManager.loadUpcomingMovies();
} }
class MyApp extends StatelessWidget { class MyApp extends StatelessWidget {
@ -22,33 +21,25 @@ class MyApp extends StatelessWidget {
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true, useMaterial3: true,
), ),
home: HomePage(), home: HomePage(movieManager),
); );
} }
} }
class HomePage extends StatelessWidget { class HomePage extends StatelessWidget {
final MovieApi api = WikidataMovieApi(); final MovieApi api = WikidataMovieApi();
final MovieManager manager;
HomePage({super.key}); HomePage(this.manager, {super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text("Release Schedule")), appBar: AppBar(title: const Text("Release Schedule")),
body: AnimatedBuilder( body: MovieManagerList(manager),
animation: movieManager, floatingActionButton: FloatingActionButton(
// future: api.getUpcomingMovies(), child: const Icon(Icons.refresh),
builder: (context, widget) { onPressed: () => manager.loadUpcomingMovies(),
return MovieList(movieManager.movies);
// var data = snapshot.data;
// if (snapshot.hasData && data != null) {
// return MovieList(data);
// } else if (snapshot.hasError) {
// return ErrorWidget(snapshot.error ?? "Something went wrong");
// }
// return const Center(child: CircularProgressIndicator());
},
), ),
); );
} }

View File

@ -0,0 +1,46 @@
import 'package:get_storage/get_storage.dart';
import 'package:release_schedule/model/movie.dart';
class LocalMovieStorage {
List<MovieData> _storedMovies = [];
update(List<MovieData> movies) {
_storedMovies = movies;
}
Future<List<MovieData>> retrieve() async {
return _storedMovies;
}
}
class LocalMovieStorageGetStorage extends LocalMovieStorage {
Future<void>? initialized;
GetStorage? container;
MovieData Function(Map jsonEncodable) toMovieData;
LocalMovieStorageGetStorage(this.toMovieData) {
initialized = _init();
}
_init() async {
await GetStorage.init("movies");
container = GetStorage("movies");
}
@override
update(List<MovieData> movies) async {
await initialized;
container!.write(
"movies", movies.map((movie) => movie.toJsonEncodable()).toList());
}
@override
Future<List<MovieData>> retrieve() async {
await initialized;
dynamic movies = container!.read("movies");
if (movies == null) {
return [];
}
return (movies as List<dynamic>)
.map((encodable) => toMovieData(encodable))
.toList();
}
}

View File

@ -7,19 +7,41 @@ class Review {
int count; int count;
Review(this.score, this.by, this.asOf, this.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,
};
}
} }
typedef ReleaseDateInCountry = (String country, DateTime date); typedef ReleaseDateInCountry = (String country, DateTime date);
typedef TitleInCountry = (String country, String title); typedef TitleInCountry = (String country, String title);
class MovieData extends ChangeNotifier { class MovieData extends ChangeNotifier {
final String title; String _title;
final DateTime releaseDate; DateTime _releaseDate;
bool _hasDetails = false; bool _hasDetails = false;
List<ReleaseDateInCountry> _releaseDates = []; List<ReleaseDateInCountry>? _releaseDates;
List<String> _genres = []; List<String>? _genres;
List<TitleInCountry> _titles = []; List<TitleInCountry>? _titles;
List<Review> _reviews = []; List<Review>? _reviews;
String get title {
return _title;
}
DateTime get releaseDate {
return _releaseDate;
}
List<ReleaseDateInCountry>? get releaseDates { List<ReleaseDateInCountry>? get releaseDates {
return _releaseDates; return _releaseDates;
@ -41,6 +63,14 @@ class MovieData extends ChangeNotifier {
return _hasDetails; return _hasDetails;
} }
void updateWithNew(MovieData movie) {
setDetails(
releaseDates: movie.releaseDates,
genres: movie.genres,
titles: movie.titles,
reviews: movie.reviews);
}
void setDetails( void setDetails(
{List<ReleaseDateInCountry>? releaseDates, {List<ReleaseDateInCountry>? releaseDates,
List<String>? genres, List<String>? genres,
@ -64,12 +94,49 @@ class MovieData extends ChangeNotifier {
@override @override
String toString() { String toString() {
return "$title (${releaseDate.year}${_genres.isNotEmpty ? "; ${_genres.join(", ")}" : ""})"; return "$title (${releaseDate.year}${_genres?.isNotEmpty ?? true ? "; ${_genres?.join(", ")}" : ""})";
}
Map toJsonEncodable() {
List? releaseDatesByCountry =
_releaseDates?.map((e) => [e.$1, e.$2.toIso8601String()]).toList();
List? titlesByCountry = _titles?.map((e) => [e.$1, e.$2]).toList();
return {
"title": title,
"releaseDate": releaseDate.toIso8601String(),
"releaseDates": releaseDatesByCountry,
"genres": genres,
"titles": titlesByCountry,
"reviews": reviews,
};
} }
bool same(MovieData other) { bool same(MovieData other) {
return title == other.title && releaseDate == other.releaseDate; return title == other.title && releaseDate == other.releaseDate;
} }
MovieData(this.title, this.releaseDate); MovieData(this._title, this._releaseDate);
MovieData.fromJsonEncodable(Map json)
: _title = json["title"],
_releaseDate = DateTime.parse(json["releaseDate"]) {
setDetails(
genres: json["genres"],
releaseDates: json["releaseDates"] != null
? (json["releaseDates"] as List<List<dynamic>>)
.map((release) => ((release[0], DateTime.parse(release[1]))
as ReleaseDateInCountry))
.toList()
: null,
reviews: json["reviews"] != null
? (json["reviews"] as List<Map<String, dynamic>>)
.map((review) => Review.fromJsonEncodable(review))
.toList()
: null,
titles: json["titles"] != null
? (json["titles"] as List<dynamic>)
.map((title) => (title[0], title[1]) as TitleInCountry)
.toList()
: null);
}
} }

View File

@ -1,6 +1,9 @@
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/api/wikidata_movie_api.dart';
import 'package:release_schedule/model/local_movie_storage.dart';
import 'package:release_schedule/model/movie.dart'; import 'package:release_schedule/model/movie.dart';
T? firstWhereOrNull<T>(List<T> list, bool Function(T element) test) { T? firstWhereOrNull<T>(List<T> list, bool Function(T element) test) {
@ -11,22 +14,69 @@ T? firstWhereOrNull<T>(List<T> list, bool Function(T element) test) {
} }
} }
final movieManager = MovieManager(WikidataMovieApi()); class DelayedFunctionCaller {
final Function function;
final Duration duration;
Timer? _timer;
class MovieManager<CustomMovieData extends MovieData> extends ChangeNotifier { DelayedFunctionCaller(this.function, this.duration);
final List<CustomMovieData> movies = List.empty(growable: true);
final MovieApi<CustomMovieData> api;
MovieManager(this.api); void call() {
// If a timer is already active, return.
if (_timer != null && _timer!.isActive) {
return;
}
List<CustomMovieData> addMovies(List<CustomMovieData> additionalMovies) { // Create a timer that calls the function after the specified duration.
List<CustomMovieData> actualMovies = []; _timer = Timer(duration, () {
function();
});
}
}
final movieManager = MovieManager(WikidataMovieApi(),
LocalMovieStorageGetStorage(WikidataMovieData.fromEncodable));
class MovieManager extends ChangeNotifier {
final List<MovieData> movies = List.empty(growable: true);
final LocalMovieStorage cache;
final MovieApi api;
bool loading = false;
DelayedFunctionCaller? cacheUpdater;
bool cacheLoaded = false;
MovieManager(this.api, this.cache) {
cacheUpdater = DelayedFunctionCaller(() {
cache.update(movies);
}, const Duration(seconds: 3));
_loadCache();
}
_loadCache() async {
addMovies(await cache.retrieve());
}
_moviesModified({bool withoutAddingOrRemoving = false}) {
cacheUpdater?.call();
if (!withoutAddingOrRemoving) {
// only notify listeners if movies are added or removed
// if they are modified in place they will notify listeners themselves
notifyListeners();
}
}
List<MovieData> addMovies(List<MovieData> additionalMovies) {
List<MovieData> actualMovies = [];
bool added = false; bool added = false;
for (var movie in additionalMovies) { for (var movie in additionalMovies) {
CustomMovieData? existing = MovieData? existing =
firstWhereOrNull(movies, (element) => movie.same(element)); firstWhereOrNull(movies, (element) => movie.same(element));
if (existing == null) { if (existing == null) {
movies.add(movie); movies.add(movie);
movie.addListener(() {
_moviesModified(withoutAddingOrRemoving: true);
});
added = true; added = true;
actualMovies.add(movie); actualMovies.add(movie);
} else { } else {
@ -34,27 +84,48 @@ class MovieManager<CustomMovieData extends MovieData> extends ChangeNotifier {
} }
} }
if (added) { if (added) {
notifyListeners(); _moviesModified();
} }
return actualMovies; return actualMovies;
} }
removeMoviesWhere(bool Function(MovieData movie) test) {
bool removedMovies = false;
for (int i = movies.length - 1; i >= 0; i--) {
bool remove = test(movies[i]);
if (remove) {
removedMovies = true;
movies.removeAt(i);
}
}
if (removedMovies) {
_moviesModified();
}
}
/// Only search locally cached movies. /// Only search locally cached movies.
localSearch(String search) {} localSearch(String search) {}
/// Online search for movies. /// Online search for movies.
Future<List<CustomMovieData>> search(String search) async { Future<List<MovieData>> search(String search) async {
List<CustomMovieData> movies = await api.searchForMovies(search); List<MovieData> movies = await api.searchForMovies(search);
return addMovies(movies); return addMovies(movies);
} }
expandDetails(List<CustomMovieData> movies) { expandDetails(List<MovieData> movies) {
api.addMovieDetails(movies); api.addMovieDetails(movies);
} }
loadUpcomingMovies() async { loadUpcomingMovies() async {
List<CustomMovieData> movies = await api.getUpcomingMovies(); try {
addMovies(movies); loading = true;
notifyListeners();
List<MovieData> movies = await api.getUpcomingMovies();
addMovies(movies);
} finally {
loading = false;
notifyListeners();
}
} }
} }

View File

@ -44,6 +44,8 @@ String durationApproximatedInWords(Duration duration) {
String durationToRelativeTimeString(Duration duration) { String durationToRelativeTimeString(Duration duration) {
if (duration.isNegative) { if (duration.isNegative) {
return "${durationApproximatedInWords(-duration)} ago"; return "${durationApproximatedInWords(-duration)} ago";
} else if (duration == Duration.zero) {
return "now";
} else { } else {
return "in ${durationApproximatedInWords(duration)}"; return "in ${durationApproximatedInWords(duration)}";
} }

View File

@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:release_schedule/model/movie_manager.dart';
import 'package:release_schedule/view/movie_list.dart';
class MovieManagerList extends StatelessWidget {
final MovieManager manager;
const MovieManagerList(this.manager, {super.key});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: manager,
builder: (context, child) {
return Column(
children: [
manager.loading ? const LinearProgressIndicator() : Container(),
Expanded(child: MovieList(manager.movies))
],
);
},
);
}
}

View File

@ -5,6 +5,8 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import path_provider_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
} }

View File

@ -57,6 +57,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.1" version: "1.3.1"
ffi:
dependency: transitive
description:
name: ffi
sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -75,6 +83,22 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
get:
dependency: transitive
description:
name: get
sha256: e4e7335ede17452b391ed3b2ede016545706c01a02292a6c97619705e7d2a85e
url: "https://pub.dev"
source: hosted
version: "4.6.6"
get_storage:
dependency: "direct main"
description:
name: get_storage
sha256: "39db1fffe779d0c22b3a744376e86febe4ade43bf65e06eab5af707dc84185a2"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@ -139,6 +163,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.8.3" version: "1.8.3"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa
url: "https://pub.dev"
source: hosted
version: "2.1.1"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: e595b98692943b4881b219f0a9e3945118d3c16bd7e2813f98ec6e532d905f72
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170"
url: "https://pub.dev"
source: hosted
version: "2.2.1"
platform:
dependency: transitive
description:
name: platform
sha256: "0a279f0707af40c890e80b1e9df8bb761694c074ba7e1d4ab1bc4b728e200b59"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d
url: "https://pub.dev"
source: hosted
version: "2.1.6"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -216,5 +304,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.4-beta" version: "0.1.4-beta"
win32:
dependency: transitive
description:
name: win32
sha256: "350a11abd2d1d97e0cc7a28a81b781c08002aa2864d9e3f192ca0ffa18b06ed3"
url: "https://pub.dev"
source: hosted
version: "5.0.9"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "589ada45ba9e39405c198fe34eb0f607cddb2108527e658136120892beac46d2"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
sdks: sdks:
dart: ">=3.1.3 <4.0.0" dart: ">=3.1.3 <4.0.0"
flutter: ">=3.13.0"

View File

@ -37,6 +37,7 @@ dependencies:
cupertino_icons: ^1.0.2 cupertino_icons: ^1.0.2
http: ^1.1.0 http: ^1.1.0
intl: ^0.18.1 intl: ^0.18.1
get_storage: ^2.1.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: