gallery.dart 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import 'dart:async';
  2. import 'package:flutter/cupertino.dart';
  3. import 'package:flutter/material.dart';
  4. import 'package:flutter/services.dart';
  5. import 'package:logging/logging.dart';
  6. import 'package:photos/events/event.dart';
  7. import 'package:photos/models/file.dart';
  8. import 'package:photos/models/selected_files.dart';
  9. import 'package:photos/ui/common_elements.dart';
  10. import 'package:photos/ui/detail_page.dart';
  11. import 'package:photos/ui/loading_widget.dart';
  12. import 'package:photos/ui/sync_indicator.dart';
  13. import 'package:photos/ui/thumbnail_widget.dart';
  14. import 'package:photos/utils/date_time_util.dart';
  15. import 'package:pull_to_refresh/pull_to_refresh.dart';
  16. class Gallery extends StatefulWidget {
  17. final List<File> Function() syncLoader;
  18. final Future<List<File>> Function(File lastFile, int limit) asyncLoader;
  19. // TODO: Verify why the event is necessary when calling loader post onRefresh
  20. // should have done the job.
  21. final Stream<Event> reloadEvent;
  22. final Future<void> Function() onRefresh;
  23. final SelectedFiles selectedFiles;
  24. final String tagPrefix;
  25. final Widget headerWidget;
  26. Gallery({
  27. this.syncLoader,
  28. this.asyncLoader,
  29. this.reloadEvent,
  30. this.onRefresh,
  31. this.headerWidget,
  32. @required this.selectedFiles,
  33. @required this.tagPrefix,
  34. });
  35. @override
  36. _GalleryState createState() {
  37. return _GalleryState();
  38. }
  39. }
  40. class _GalleryState extends State<Gallery> {
  41. static final int kLoadLimit = 200;
  42. static final int kEagerLoadTrigger = 10;
  43. final Logger _logger = Logger("Gallery");
  44. final List<List<File>> _collatedFiles = List<List<File>>();
  45. ScrollController _scrollController = ScrollController();
  46. bool _requiresLoad = false;
  47. bool _hasLoadedAll = false;
  48. bool _isLoadingNext = false;
  49. double _scrollOffset = 0;
  50. List<File> _files;
  51. RefreshController _refreshController = RefreshController();
  52. @override
  53. void initState() {
  54. _requiresLoad = true;
  55. if (widget.reloadEvent != null) {
  56. widget.reloadEvent.listen((event) {
  57. setState(() {
  58. _requiresLoad = true;
  59. });
  60. });
  61. }
  62. widget.selectedFiles.addListener(() {
  63. setState(() {
  64. _saveScrollPosition();
  65. });
  66. });
  67. if (widget.asyncLoader == null) {
  68. _hasLoadedAll = true;
  69. }
  70. super.initState();
  71. }
  72. @override
  73. Widget build(BuildContext context) {
  74. _logger.info("Building");
  75. if (!_requiresLoad) {
  76. return _onDataLoaded();
  77. }
  78. if (widget.syncLoader != null) {
  79. _files = widget.syncLoader();
  80. return _onDataLoaded();
  81. }
  82. return FutureBuilder<List<File>>(
  83. future: widget.asyncLoader(null, kLoadLimit),
  84. builder: (context, snapshot) {
  85. if (snapshot.hasData) {
  86. _requiresLoad = false;
  87. _files = snapshot.data;
  88. return _onDataLoaded();
  89. } else if (snapshot.hasError) {
  90. _requiresLoad = false;
  91. return Center(child: Text(snapshot.error.toString()));
  92. } else {
  93. return Center(child: loadWidget);
  94. }
  95. },
  96. );
  97. }
  98. Widget _onDataLoaded() {
  99. _logger.info("Loaded " + _files.length.toString());
  100. if (_files.isEmpty) {
  101. return nothingToSeeHere;
  102. }
  103. _collateFiles();
  104. _logger.info("Collated length " + _collatedFiles.length.toString());
  105. _scrollController = ScrollController(
  106. initialScrollOffset: _scrollOffset,
  107. );
  108. final list = ListView.builder(
  109. itemCount:
  110. _collatedFiles.length + (widget.headerWidget == null ? 1 : 2), // h4ck
  111. itemBuilder: _buildListItem,
  112. controller: _scrollController,
  113. cacheExtent: 1500,
  114. addAutomaticKeepAlives: true,
  115. );
  116. if (widget.onRefresh != null) {
  117. return SmartRefresher(
  118. controller: _refreshController,
  119. child: list,
  120. header: SyncIndicator(_refreshController),
  121. onRefresh: () {
  122. widget.onRefresh().then((_) {
  123. _refreshController.refreshCompleted();
  124. setState(() {
  125. _requiresLoad = true;
  126. });
  127. }).catchError((e) {
  128. _refreshController.refreshFailed();
  129. setState(() {});
  130. });
  131. },
  132. );
  133. } else {
  134. return list;
  135. }
  136. }
  137. Widget _buildListItem(BuildContext context, int index) {
  138. if (_shouldLoadNextItems(index)) {
  139. // Eagerly load next batch
  140. _loadNextItems();
  141. }
  142. var fileIndex;
  143. if (widget.headerWidget != null) {
  144. if (index == 0) {
  145. return widget.headerWidget;
  146. }
  147. fileIndex = index - 1;
  148. } else {
  149. fileIndex = index;
  150. }
  151. if (fileIndex == _collatedFiles.length) {
  152. if (widget.asyncLoader != null) {
  153. if (!_hasLoadedAll) {
  154. return loadWidget;
  155. } else {
  156. return Container();
  157. }
  158. }
  159. }
  160. if (fileIndex < 0 || fileIndex >= _collatedFiles.length) {
  161. return Container();
  162. }
  163. var files = _collatedFiles[fileIndex];
  164. return Column(
  165. children: <Widget>[_getDay(files[0].creationTime), _getGallery(files)],
  166. );
  167. }
  168. bool _shouldLoadNextItems(int index) =>
  169. widget.asyncLoader != null &&
  170. !_isLoadingNext &&
  171. (index >= _collatedFiles.length - kEagerLoadTrigger) &&
  172. !_hasLoadedAll;
  173. void _loadNextItems() {
  174. _isLoadingNext = true;
  175. widget.asyncLoader(_files[_files.length - 1], kLoadLimit).then((files) {
  176. setState(() {
  177. _isLoadingNext = false;
  178. _saveScrollPosition();
  179. if (files.length < kLoadLimit) {
  180. _hasLoadedAll = true;
  181. } else {
  182. _files.addAll(files);
  183. }
  184. });
  185. });
  186. }
  187. void _saveScrollPosition() {
  188. _scrollOffset = _scrollController.offset;
  189. }
  190. Widget _getDay(int timestamp) {
  191. return Container(
  192. padding: const EdgeInsets.all(8.0),
  193. alignment: Alignment.centerLeft,
  194. child: Text(
  195. getDayAndMonth(DateTime.fromMicrosecondsSinceEpoch(timestamp)),
  196. style: TextStyle(fontSize: 16),
  197. ),
  198. );
  199. }
  200. Widget _getGallery(List<File> files) {
  201. _logger.info("Building gallery with " + files.length.toString());
  202. return GridView.builder(
  203. shrinkWrap: true,
  204. padding: EdgeInsets.only(bottom: 12),
  205. physics:
  206. NeverScrollableScrollPhysics(), // to disable GridView's scrolling
  207. itemBuilder: (context, index) {
  208. return _buildFile(context, files[index]);
  209. },
  210. itemCount: files.length,
  211. gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
  212. crossAxisCount: 4,
  213. ),
  214. );
  215. }
  216. Widget _buildFile(BuildContext context, File file) {
  217. return GestureDetector(
  218. onTap: () {
  219. if (widget.selectedFiles.files.isNotEmpty) {
  220. _selectFile(file);
  221. } else {
  222. _routeToDetailPage(file, context);
  223. }
  224. },
  225. onLongPress: () {
  226. HapticFeedback.lightImpact();
  227. _selectFile(file);
  228. },
  229. child: Container(
  230. margin: const EdgeInsets.all(2.0),
  231. decoration: BoxDecoration(
  232. border: widget.selectedFiles.files.contains(file)
  233. ? Border.all(width: 4.0, color: Colors.blue)
  234. : null,
  235. ),
  236. child: Hero(
  237. tag: widget.tagPrefix + file.tag(),
  238. child: ThumbnailWidget(file),
  239. ),
  240. ),
  241. );
  242. }
  243. void _selectFile(File file) {
  244. widget.selectedFiles.toggleSelection(file);
  245. }
  246. void _routeToDetailPage(File file, BuildContext context) {
  247. final page = DetailPage(
  248. _files,
  249. _files.indexOf(file),
  250. widget.tagPrefix,
  251. );
  252. Navigator.of(context).push(
  253. MaterialPageRoute(
  254. builder: (BuildContext context) {
  255. return page;
  256. },
  257. ),
  258. );
  259. }
  260. void _collateFiles() {
  261. final dailyFiles = List<File>();
  262. final collatedFiles = List<List<File>>();
  263. for (int index = 0; index < _files.length; index++) {
  264. if (index > 0 &&
  265. !_areFilesFromSameDay(_files[index - 1], _files[index])) {
  266. final collatedDailyFiles = List<File>();
  267. collatedDailyFiles.addAll(dailyFiles);
  268. collatedFiles.add(collatedDailyFiles);
  269. dailyFiles.clear();
  270. }
  271. dailyFiles.add(_files[index]);
  272. }
  273. if (dailyFiles.isNotEmpty) {
  274. collatedFiles.add(dailyFiles);
  275. }
  276. _collatedFiles.clear();
  277. _collatedFiles.addAll(collatedFiles);
  278. }
  279. bool _areFilesFromSameDay(File first, File second) {
  280. var firstDate = DateTime.fromMicrosecondsSinceEpoch(first.creationTime);
  281. var secondDate = DateTime.fromMicrosecondsSinceEpoch(second.creationTime);
  282. return firstDate.year == secondDate.year &&
  283. firstDate.month == secondDate.month &&
  284. firstDate.day == secondDate.day;
  285. }
  286. }