diff --git a/lib/api/movie_api.dart b/lib/api/movie_api.dart index 245dd58..03d9b83 100644 --- a/lib/api/movie_api.dart +++ b/lib/api/movie_api.dart @@ -1,7 +1,7 @@ import 'package:release_schedule/model/movie.dart'; -abstract class MovieApi { - Future> getUpcomingMovies([int count]); - Future> searchForMovies(String searchTerm); - Future addMovieDetails(List movies); +abstract class MovieApi { + Future> getUpcomingMovies([int count]); + Future> searchForMovies(String searchTerm); + Future addMovieDetails(List movies); } diff --git a/lib/api/wikidata_movie_api.dart b/lib/api/wikidata_movie_api.dart index 899dac4..7b6bf63 100644 --- a/lib/api/wikidata_movie_api.dart +++ b/lib/api/wikidata_movie_api.dart @@ -10,10 +10,19 @@ class WikidataMovieData extends MovieData { WikidataMovieData(String title, DateTime releaseDate, this.entityId) : super(title, releaseDate); + WikidataMovieData.fromEncodable(Map encodable) + : entityId = encodable["entityId"], + super.fromJsonEncodable(encodable); + @override bool same(MovieData other) { return other is WikidataMovieData && entityId == other.entityId; } + + @override + Map toJsonEncodable() { + return super.toJsonEncodable()..addAll({"entityId": entityId}); + } } String createUpcomingMovieQuery(int limit) { @@ -35,13 +44,13 @@ ORDER BY ?minReleaseDate LIMIT $limit"""; } -class WikidataMovieApi implements MovieApi { +class WikidataMovieApi implements MovieApi { ApiManager searchApi = ApiManager("https://www.wikidata.org/w/api.php"); ApiManager queryApi = ApiManager("https://query.wikidata.org/sparql?format=json"); @override - Future addMovieDetails(List movies) { + Future addMovieDetails(List movies) { // TODO: implement addMovieDetails throw UnimplementedError(); } diff --git a/lib/main.dart b/lib/main.dart index 784edcb..23b95be 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,11 +2,10 @@ import 'package:flutter/material.dart'; import 'package:release_schedule/api/movie_api.dart'; import 'package:release_schedule/api/wikidata_movie_api.dart'; import 'package:release_schedule/model/movie_manager.dart'; -import 'package:release_schedule/view/movie_list.dart'; +import 'package:release_schedule/view/movie_manager_list.dart'; void main() { runApp(const MyApp()); - movieManager.loadUpcomingMovies(); } class MyApp extends StatelessWidget { @@ -22,33 +21,25 @@ class MyApp extends StatelessWidget { colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true, ), - home: HomePage(), + home: HomePage(movieManager), ); } } class HomePage extends StatelessWidget { final MovieApi api = WikidataMovieApi(); + final MovieManager manager; - HomePage({super.key}); + HomePage(this.manager, {super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("Release Schedule")), - body: AnimatedBuilder( - animation: movieManager, - // future: api.getUpcomingMovies(), - builder: (context, widget) { - return MovieList(movieManager.movies); - // var data = snapshot.data; - // if (snapshot.hasData && data != null) { - // return MovieList(data); - // } else if (snapshot.hasError) { - // return ErrorWidget(snapshot.error ?? "Something went wrong"); - // } - // return const Center(child: CircularProgressIndicator()); - }, + body: MovieManagerList(manager), + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.refresh), + onPressed: () => manager.loadUpcomingMovies(), ), ); } diff --git a/lib/model/local_movie_storage.dart b/lib/model/local_movie_storage.dart new file mode 100644 index 0000000..a0ce1a3 --- /dev/null +++ b/lib/model/local_movie_storage.dart @@ -0,0 +1,46 @@ +import 'package:get_storage/get_storage.dart'; +import 'package:release_schedule/model/movie.dart'; + +class LocalMovieStorage { + List _storedMovies = []; + update(List movies) { + _storedMovies = movies; + } + + Future> retrieve() async { + return _storedMovies; + } +} + +class LocalMovieStorageGetStorage extends LocalMovieStorage { + Future? 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 movies) async { + await initialized; + container!.write( + "movies", movies.map((movie) => movie.toJsonEncodable()).toList()); + } + + @override + Future> retrieve() async { + await initialized; + dynamic movies = container!.read("movies"); + if (movies == null) { + return []; + } + return (movies as List) + .map((encodable) => toMovieData(encodable)) + .toList(); + } +} diff --git a/lib/model/movie.dart b/lib/model/movie.dart index 0a114d2..3e5fbab 100644 --- a/lib/model/movie.dart +++ b/lib/model/movie.dart @@ -7,19 +7,41 @@ class Review { 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, + }; + } } typedef ReleaseDateInCountry = (String country, DateTime date); typedef TitleInCountry = (String country, String title); class MovieData extends ChangeNotifier { - final String title; - final DateTime releaseDate; + String _title; + DateTime _releaseDate; bool _hasDetails = false; - List _releaseDates = []; - List _genres = []; - List _titles = []; - List _reviews = []; + List? _releaseDates; + List? _genres; + List? _titles; + List? _reviews; + + String get title { + return _title; + } + + DateTime get releaseDate { + return _releaseDate; + } List? get releaseDates { return _releaseDates; @@ -41,6 +63,14 @@ class MovieData extends ChangeNotifier { return _hasDetails; } + void updateWithNew(MovieData movie) { + setDetails( + releaseDates: movie.releaseDates, + genres: movie.genres, + titles: movie.titles, + reviews: movie.reviews); + } + void setDetails( {List? releaseDates, List? genres, @@ -64,12 +94,49 @@ class MovieData extends ChangeNotifier { @override 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) { 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>) + .map((release) => ((release[0], DateTime.parse(release[1])) + as ReleaseDateInCountry)) + .toList() + : null, + reviews: json["reviews"] != null + ? (json["reviews"] as List>) + .map((review) => Review.fromJsonEncodable(review)) + .toList() + : null, + titles: json["titles"] != null + ? (json["titles"] as List) + .map((title) => (title[0], title[1]) as TitleInCountry) + .toList() + : null); + } } diff --git a/lib/model/movie_manager.dart b/lib/model/movie_manager.dart index 56bab8a..a573682 100644 --- a/lib/model/movie_manager.dart +++ b/lib/model/movie_manager.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:release_schedule/api/movie_api.dart'; import 'package:release_schedule/api/wikidata_movie_api.dart'; +import 'package:release_schedule/model/local_movie_storage.dart'; import 'package:release_schedule/model/movie.dart'; T? firstWhereOrNull(List list, bool Function(T element) test) { @@ -11,22 +14,69 @@ T? firstWhereOrNull(List list, bool Function(T element) test) { } } -final movieManager = MovieManager(WikidataMovieApi()); +class DelayedFunctionCaller { + final Function function; + final Duration duration; + Timer? _timer; -class MovieManager extends ChangeNotifier { - final List movies = List.empty(growable: true); - final MovieApi api; + DelayedFunctionCaller(this.function, this.duration); - MovieManager(this.api); + void call() { + // If a timer is already active, return. + if (_timer != null && _timer!.isActive) { + return; + } - List addMovies(List additionalMovies) { - List actualMovies = []; + // Create a timer that calls the function after the specified duration. + _timer = Timer(duration, () { + function(); + }); + } +} + +final movieManager = MovieManager(WikidataMovieApi(), + LocalMovieStorageGetStorage(WikidataMovieData.fromEncodable)); + +class MovieManager extends ChangeNotifier { + final List 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 addMovies(List additionalMovies) { + List actualMovies = []; bool added = false; for (var movie in additionalMovies) { - CustomMovieData? existing = + MovieData? existing = firstWhereOrNull(movies, (element) => movie.same(element)); if (existing == null) { movies.add(movie); + movie.addListener(() { + _moviesModified(withoutAddingOrRemoving: true); + }); added = true; actualMovies.add(movie); } else { @@ -34,27 +84,48 @@ class MovieManager extends ChangeNotifier { } } if (added) { - notifyListeners(); + _moviesModified(); } 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. localSearch(String search) {} /// Online search for movies. - Future> search(String search) async { - List movies = await api.searchForMovies(search); + Future> search(String search) async { + List movies = await api.searchForMovies(search); return addMovies(movies); } - expandDetails(List movies) { + expandDetails(List movies) { api.addMovieDetails(movies); } loadUpcomingMovies() async { - List movies = await api.getUpcomingMovies(); - addMovies(movies); + try { + loading = true; + notifyListeners(); + List movies = await api.getUpcomingMovies(); + addMovies(movies); + } finally { + loading = false; + notifyListeners(); + } } } diff --git a/lib/view/movie_item.dart b/lib/view/movie_item.dart index 461109b..f21eaa3 100644 --- a/lib/view/movie_item.dart +++ b/lib/view/movie_item.dart @@ -44,6 +44,8 @@ String durationApproximatedInWords(Duration duration) { String durationToRelativeTimeString(Duration duration) { if (duration.isNegative) { return "${durationApproximatedInWords(-duration)} ago"; + } else if (duration == Duration.zero) { + return "now"; } else { return "in ${durationApproximatedInWords(duration)}"; } diff --git a/lib/view/movie_manager_list.dart b/lib/view/movie_manager_list.dart new file mode 100644 index 0000000..fae20ee --- /dev/null +++ b/lib/view/movie_manager_list.dart @@ -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)) + ], + ); + }, + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..e777c67 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import path_provider_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 2285f19..3b6f4b0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + url: "https://pub.dev" + source: hosted + version: "2.1.0" flutter: dependency: "direct main" description: flutter @@ -75,6 +83,22 @@ packages: description: flutter source: sdk 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: dependency: "direct main" description: @@ -139,6 +163,70 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: flutter @@ -216,5 +304,22 @@ packages: url: "https://pub.dev" source: hosted 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: dart: ">=3.1.3 <4.0.0" + flutter: ">=3.13.0" diff --git a/pubspec.yaml b/pubspec.yaml index 7bdb621..ae43aa3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: cupertino_icons: ^1.0.2 http: ^1.1.0 intl: ^0.18.1 + get_storage: ^2.1.1 dev_dependencies: flutter_test: