diff --git a/packages/firebase_ui_storage/README.md b/packages/firebase_ui_storage/README.md index 7229ba425296..9d0973671600 100644 --- a/packages/firebase_ui_storage/README.md +++ b/packages/firebase_ui_storage/README.md @@ -139,3 +139,36 @@ class MyUploadProgress extends StatelessWidget { } } ``` + +### StorageListView + +```dart +StorageListView( + ref: storage.ref('images'), + itemBuilder: (context, ref) { + return AspectRatio( + aspectRatio: 1, + child: StorageImage(ref: ref), + ); + }, + loadingBuilder: (context) { + return const Center( + child: CircularProgressIndicator(), + ); + }, + errorBuilder: (context, error, controller) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Error: $error'), + TextButton( + onPressed: () => controller.load(), + child: const Text('Retry'), + ), + ], + ), + ); + }, +); +``` diff --git a/packages/firebase_ui_storage/example/lib/src/apps.dart b/packages/firebase_ui_storage/example/lib/src/apps.dart index 942e66e40b3e..b388284b5835 100644 --- a/packages/firebase_ui_storage/example/lib/src/apps.dart +++ b/packages/firebase_ui_storage/example/lib/src/apps.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'progress_bar_app.dart'; import 'upload_button_app.dart'; +import 'list_view_app.dart'; abstract class App implements Widget { String get name; @@ -17,6 +18,7 @@ const apps = [ UploadButtonApp(), ProgressBarApp(), StorageImageApp(), + StorageListViewApp(), ]; class AppList extends StatelessWidget { diff --git a/packages/firebase_ui_storage/example/lib/src/list_view_app.dart b/packages/firebase_ui_storage/example/lib/src/list_view_app.dart new file mode 100644 index 000000000000..25cebc2f59b6 --- /dev/null +++ b/packages/firebase_ui_storage/example/lib/src/list_view_app.dart @@ -0,0 +1,40 @@ +// Copyright 2023, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:firebase_ui_storage/firebase_ui_storage.dart'; +import 'package:flutter/material.dart'; + +import 'apps.dart'; + +class StorageListViewApp extends StatelessWidget implements App { + const StorageListViewApp({super.key}); + + @override + String get name => 'StorageListView'; + + @override + Widget build(BuildContext context) { + return StorageListView( + ref: FirebaseStorage.instance.ref('list'), + itemBuilder: (context, ref) { + return ListTile( + title: FutureBuilder( + future: ref.getData(), + builder: (context, snapshot) { + if (snapshot.hasError) { + return Text(snapshot.error.toString()); + } + if (snapshot.hasData) { + return Text(snapshot.data.toString()); + } + + return const Text('Loading...'); + }, + ), + ); + }, + ); + } +} diff --git a/packages/firebase_ui_storage/example/pubspec.yaml b/packages/firebase_ui_storage/example/pubspec.yaml index 4ce2bf785270..41c05cc24b6f 100644 --- a/packages/firebase_ui_storage/example/pubspec.yaml +++ b/packages/firebase_ui_storage/example/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: 'none' version: 0.1.0 environment: - sdk: '>=2.19.5 <4.0.0' + sdk: '>=3.0.0 <4.0.0' dependencies: cupertino_icons: ^1.0.3 diff --git a/packages/firebase_ui_storage/lib/firebase_ui_storage.dart b/packages/firebase_ui_storage/lib/firebase_ui_storage.dart index ba3b20283821..dd2969e81032 100644 --- a/packages/firebase_ui_storage/lib/firebase_ui_storage.dart +++ b/packages/firebase_ui_storage/lib/firebase_ui_storage.dart @@ -20,3 +20,5 @@ export 'src/widgets/progress_indicator.dart' show TaskProgressIndicator, TaskProgressWidget, ErrorBuilder; export 'src/widgets/image.dart' show StorageImage, LoadingStateVariant; +export 'src/paginated_loading_controller.dart'; +export 'src/widgets/list_view.dart'; diff --git a/packages/firebase_ui_storage/lib/src/paginated_loading_controller.dart b/packages/firebase_ui_storage/lib/src/paginated_loading_controller.dart new file mode 100644 index 000000000000..df47ee70ba3f --- /dev/null +++ b/packages/firebase_ui_storage/lib/src/paginated_loading_controller.dart @@ -0,0 +1,104 @@ +// Copyright 2023, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:flutter/foundation.dart'; + +/// A base class for loading states. +sealed class PaginatedLoadingState { + const PaginatedLoadingState(); +} + +/// Indicates that the first page is loading. +class InitialPageLoading extends PaginatedLoadingState { + const InitialPageLoading(); +} + +class PageLoading extends PaginatedLoadingState { + final List items; + + const PageLoading({required this.items}); +} + +class PageLoadComplete extends PaginatedLoadingState { + final List pageItems; + final List items; + + const PageLoadComplete({ + required this.pageItems, + required this.items, + }); +} + +class PageLoadError extends PaginatedLoadingState { + final Object? error; + final List? items; + + const PageLoadError({ + required this.error, + this.items, + }); +} + +class PaginatedLoadingController extends ChangeNotifier { + int pageSize; + final Reference ref; + + PaginatedLoadingController({ + required this.ref, + this.pageSize = 50, + }) { + load(); + } + + PaginatedLoadingState? _state; + PaginatedLoadingState get state => _state!; + + ListResult? _cursor; + List? _items; + + ListOptions get _listOptions { + return ListOptions( + maxResults: pageSize, + pageToken: _cursor?.nextPageToken, + ); + } + + Future load() { + _state = _state == null + ? const InitialPageLoading() + : PageLoading(items: _items!); + + notifyListeners(); + + return ref.list(_listOptions).then((value) { + _cursor = value; + (_items ??= []).addAll(value.items); + + _state = PageLoadComplete( + pageItems: value.items, + items: _items!, + ); + + notifyListeners(); + }).catchError((e) { + _state = PageLoadError( + error: e, + items: _items, + ); + + notifyListeners(); + }); + } + + bool shouldLoadNextPage(int itemIndex) { + return switch (state) { + InitialPageLoading() => false, + PageLoading() => false, + PageLoadComplete(items: final items) => + itemIndex == (items.length - pageSize + 1), + PageLoadError() => false, + }; + } +} diff --git a/packages/firebase_ui_storage/lib/src/widgets/list_view.dart b/packages/firebase_ui_storage/lib/src/widgets/list_view.dart new file mode 100644 index 000000000000..71c90cb7c8d5 --- /dev/null +++ b/packages/firebase_ui_storage/lib/src/widgets/list_view.dart @@ -0,0 +1,251 @@ +// Copyright 2023, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:firebase_ui_shared/firebase_ui_shared.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +import '../paginated_loading_controller.dart'; + +Widget _defaultLoadingBuilder(BuildContext context) { + return const Center( + child: LoadingIndicator( + size: 32, + borderWidth: 2, + ), + ); +} + +/// A [ListView.builder] that automatically handles paginated loading from +/// [FirebaseStorage]. +/// +/// Example usage: +/// +/// ```dart +/// StorageListView( +/// ref: storage.ref('images'), +/// itemBuilder: (context, ref) { +/// return AspectRatio( +/// aspectRatio: 1, +/// child: StorageImage(ref: ref), +/// ); +/// }, +/// loadingBuilder: (context) { +/// return const Center( +/// child: CircularProgressIndicator(), +/// ); +/// }, +/// errorBuilder: (context, error, controller) { +/// return Center( +/// child: Column( +/// mainAxisSize: MainAxisSize.min, +/// children: [ +/// Text('Error: $error'), +/// TextButton( +/// onPressed: () => controller.load(), +/// child: const Text('Retry'), +/// ), +/// ], +/// ), +/// ); +/// }, +/// ) +class StorageListView extends StatefulWidget { + /// The [Reference] to list items from. + /// If not provided, a [loadingController] must be created and passed. + final Reference? ref; + + /// The number of items to load per page. + /// Defaults to 50. + final int pageSize; + + /// A builder that is called for the first page load. + final Widget Function(BuildContext context) loadingBuilder; + + /// A builder that is called when an error occurs during page loading. + final Widget Function( + BuildContext context, + Object? error, + PaginatedLoadingController controller, + )? errorBuilder; + + /// A builder that is called for each item in the list. + final Widget Function(BuildContext context, Reference ref) itemBuilder; + + /// A controller that can be used to listen for all page loading states. + /// + /// If this is not provided, a new controller will be created with a given + /// [ref] and [pageSize]. + final PaginatedLoadingController? loadingController; + + /// See [SliverChildBuilderDelegate.addAutomaticKeepAlives]. + final bool addAutomaticKeepAlives; + + /// See [SliverChildBuilderDelegate.addRepaintBoundaries]. + final bool addRepaintBoundaries; + + /// See [SliverChildBuilderDelegate.addSemanticIndexes]. + final bool addSemanticIndexes; + + /// See [ScrollView.cacheExtent]. + final double? cacheExtent; + + /// See [ScrollView.clipBehavior]. + final Clip clipBehavior; + + /// See [ScrollView.controller]. + final ScrollController? controller; + + /// See [ScrollView.dragStartBehavior]. + final DragStartBehavior dragStartBehavior; + + /// See [SliverChildBuilderDelegate.findChildIndexCallback]. + final int? Function(Key key)? findChildIndexCallback; + + /// See [ListView.itemExtent]. + final double? itemExtent; + + /// See [ScrollView.keyboardDismissBehavior]. + final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; + + /// See [ListView.padding]. + final EdgeInsetsGeometry? padding; + + /// See [ListView.prototypeItem]. + final Widget? prototypeItem; + + /// See [ScrollView.physics]. + final ScrollPhysics? physics; + + /// See [ScrollView.primary]. + final bool? primary; + + /// See [ScrollView.restorationId]. + final String? restorationId; + + /// See [ScrollView.reverse]. + final bool reverse; + + /// See [ScrollView.scrollDirection]. + final Axis scrollDirection; + + /// See [ListView.semanticChildCount]. + final int? semanticChildCount; + + /// See [ScrollView.shrinkWrap]. + final bool shrinkWrap; + + const StorageListView({ + super.key, + required this.itemBuilder, + this.ref, + this.pageSize = 50, + this.loadingBuilder = _defaultLoadingBuilder, + this.errorBuilder, + this.loadingController, + this.addAutomaticKeepAlives = true, + this.addRepaintBoundaries = true, + this.addSemanticIndexes = true, + this.cacheExtent, + this.clipBehavior = Clip.hardEdge, + this.controller, + this.dragStartBehavior = DragStartBehavior.start, + this.findChildIndexCallback, + this.itemExtent, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + this.padding, + this.prototypeItem, + this.physics, + this.primary, + this.restorationId, + this.reverse = false, + this.scrollDirection = Axis.vertical, + this.semanticChildCount, + this.shrinkWrap = false, + }) : assert( + ref != null || loadingController != null, + 'ref or loadingController must be provided', + ); + + @override + State createState() => _StorageListViewState(); +} + +class _StorageListViewState extends State { + late PaginatedLoadingController ctrl = widget.loadingController ?? + PaginatedLoadingController( + ref: widget.ref!, + pageSize: widget.pageSize, + ); + + @override + void didUpdateWidget(covariant StorageListView oldWidget) { + if (oldWidget.pageSize != widget.pageSize) { + ctrl.pageSize = widget.pageSize; + } + + if (oldWidget.ref != widget.ref) { + ctrl = PaginatedLoadingController( + ref: widget.ref!, + pageSize: widget.pageSize, + ); + } + + super.didUpdateWidget(oldWidget); + } + + Widget listBuilder(BuildContext context, List items) { + return ListView.builder( + itemBuilder: (context, index) { + if (ctrl.shouldLoadNextPage(index)) { + ctrl.load(); + } + + return widget.itemBuilder(context, items[index]); + }, + itemCount: items.length, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + addRepaintBoundaries: widget.addRepaintBoundaries, + addSemanticIndexes: widget.addSemanticIndexes, + cacheExtent: widget.cacheExtent, + clipBehavior: widget.clipBehavior, + controller: widget.controller, + dragStartBehavior: widget.dragStartBehavior, + findChildIndexCallback: widget.findChildIndexCallback, + itemExtent: widget.itemExtent, + keyboardDismissBehavior: widget.keyboardDismissBehavior, + padding: widget.padding, + prototypeItem: widget.prototypeItem, + physics: widget.physics, + primary: widget.primary, + restorationId: widget.restorationId, + reverse: widget.reverse, + scrollDirection: widget.scrollDirection, + semanticChildCount: widget.semanticChildCount, + shrinkWrap: widget.shrinkWrap, + ); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: ctrl, + builder: (context, _) { + return switch (ctrl.state) { + InitialPageLoading() => widget.loadingBuilder(context), + PageLoadError( + error: final error, + items: final items, + ) => + widget.errorBuilder != null + ? widget.errorBuilder!(context, error, ctrl) + : listBuilder(context, items ?? []), + PageLoading(items: final items) => listBuilder(context, items), + PageLoadComplete(items: final items) => listBuilder(context, items), + }; + }, + ); + } +} diff --git a/packages/firebase_ui_storage/pubspec.yaml b/packages/firebase_ui_storage/pubspec.yaml index feddb3c25003..920d1e384c87 100644 --- a/packages/firebase_ui_storage/pubspec.yaml +++ b/packages/firebase_ui_storage/pubspec.yaml @@ -4,8 +4,8 @@ version: 0.1.0-dev.7 homepage: https://github.com/firebase/flutterfire/tree/master/packages/firebase_ui_storage/ environment: - sdk: '>=2.19.5 <4.0.0' - flutter: '>=3.3.0' + sdk: '>=3.0.0 <4.0.0' + flutter: '>=3.10.0' false_secrets: - example/**