├── .gitignore ├── .metadata ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── lib ├── loader.dart └── src │ ├── auto_loader.dart │ ├── auto_loader_list.dart │ └── widgets.dart ├── pubspec.yaml └── test └── loader_test.dart /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .dart_tool/ 3 | 4 | .packages 5 | .pub/ 6 | 7 | build/ 8 | ios/.generated/ 9 | ios/Flutter/Generated.xcconfig 10 | ios/Runner/GeneratedPluginRegistrant.* 11 | 12 | *.iml 13 | pubspec.lock 14 | 15 | .idea/ -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 9937d3dfb1569e658cee30cc514c59a47102e3a5 8 | channel: master 9 | 10 | project_type: package 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: 2 | - linux 3 | sudo: false 4 | addons: 5 | apt: 6 | sources: 7 | - ubuntu-toolchain-r-test 8 | packages: 9 | - libstdc++6 10 | before_script: 11 | - git clone https://github.com/flutter/flutter.git -b stable --depth 1 12 | - ./flutter/bin/flutter doctor 13 | script: 14 | - ./flutter/bin/flutter test --coverage --coverage-path=lcov.info 15 | after_success: 16 | - bash <(curl -s https://codecov.io/bash) 17 | cache: 18 | directories: 19 | - $HOME/.pub-cache 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.0.1] - 2019/5/3 2 | 3 | * init 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 版权所有(c)<2019> 2 | 3 | 反996许可证版本1.0 4 | 5 | 在符合下列条件的情况下,特此免费向任何得到本授权作品的副本(包括源代码、文件和/或相关内容,以 6 | 下统称为“授权作品”)的个人和法人实体授权:被授权个人或法人实体有权以任何目的处置授权作品,包括 7 | 但不限于使用、复制,修改,衍生利用、散布,发布和再许可: 8 | 9 | 1. 个人或法人实体必须在许可作品的每个再散布或衍生副本上包含以上版权声明和本许可证,不得自行修 10 | 改。 11 | 2. 个人或法人实体必须严格遵守与个人实际所在地或个人出生地或归化地、或法人实体注册地或经营地( 12 | 以较严格者为准)的司法管辖区所有适用的与劳动和就业相关法律、法规、规则和标准。如果该司法管辖区 13 | 没有此类法律、法规、规章和标准或其法律、法规、规章和标准不可执行,则个人或法人实体必须遵守国际 14 | 劳工标准的核心公约。 15 | 3. 个人或法人不得以任何方式诱导、暗示或强迫其全职或兼职员工或其独立承包人以口头或书面形式同意 16 | 直接或间接限制、削弱或放弃其所拥有的,受相关与劳动和就业有关的法律、法规、规则和标准保护的权利 17 | 或补救措施,无论该等书面或口头协议是否被该司法管辖区的法律所承认,该等个人或法人实体也不得以任 18 | 何方法限制其雇员或独立承包人向版权持有人或监督许可证合规情况的有关当局报告或投诉上述违反许可证 19 | 的行为的权利。 20 | 21 | 该授权作品是"按原样"提供,不做任何明示或暗示的保证,包括但不限于对适销性、特定用途适用性和非侵 22 | 权性的保证。在任何情况下,无论是在合同诉讼、侵权诉讼或其他诉讼中,版权持有人均不承担因本软件或 23 | 本软件的使用或其他交易而产生、引起或与之相关的任何索赔、损害或其他责任。 24 | 25 | -------------- 26 | 27 | MIT License 28 | 29 | Copyright (c) 2019 伯言 30 | 31 | Permission is hereby granted, free of charge, to any person obtaining a copy 32 | of this software and associated documentation files (the "Software"), to deal 33 | in the Software without restriction, including without limitation the rights 34 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 35 | copies of the Software, and to permit persons to whom the Software is 36 | furnished to do so, subject to the following conditions: 37 | 38 | The above copyright notice and this permission notice shall be included in all 39 | copies or substantial portions of the Software. 40 | 41 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 42 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 43 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 44 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 45 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 46 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 47 | SOFTWARE. 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # loader 2 | [![Build Status](https://travis-ci.com/boyan01/loader.svg?branch=master)](https://travis-ci.com/boyan01/loader) 3 | [![codecov](https://codecov.io/gh/boyan01/loader/branch/master/graph/badge.svg)](https://codecov.io/gh/boyan01/loader) 4 | 5 | 加载网络数据时自动控制Widget的状态 6 | 7 | 8 | ## 使用方式 9 | 10 | ```yaml 11 | dependencies: 12 | loader: 13 | git: 14 | url: git://github.com/boyan01/loader.git 15 | refs: tags/v0.1.0 16 | ``` 17 | 18 | 19 | 20 | 例子: [这个项目](https://github.com/boyan01/flutter-netease-music) 21 | 22 | 23 | 24 | ## License 25 | 26 | MIT with 996 License 27 | -------------------------------------------------------------------------------- /lib/loader.dart: -------------------------------------------------------------------------------- 1 | library loader; 2 | 3 | import 'dart:async'; 4 | 5 | import 'package:async/async.dart'; 6 | import 'package:flutter/cupertino.dart'; 7 | import 'package:flutter/material.dart'; 8 | import 'package:loader/src/widgets.dart'; 9 | import 'package:scoped_model/scoped_model.dart'; 10 | 11 | export 'package:async/async.dart' show Result; 12 | export 'package:async/async.dart' show ErrorResult; 13 | export 'package:async/async.dart' show ValueResult; 14 | 15 | export 'src/auto_loader_list.dart'; 16 | 17 | part 'src/auto_loader.dart'; 18 | 19 | ///build widget when Loader has completed loading... 20 | typedef LoaderWidgetBuilder = Widget Function( 21 | BuildContext context, T result); 22 | 23 | void _defaultFailedHandler(BuildContext context, ErrorResult result) { 24 | debugPrint("error:\n ${result.stackTrace}"); 25 | } 26 | 27 | class Loader extends StatefulWidget { 28 | const Loader( 29 | {Key? key, 30 | required this.loadTask, 31 | required this.builder, 32 | this.loadingBuilder, 33 | this.initialData, 34 | this.onError = _defaultFailedHandler, 35 | this.errorBuilder}) 36 | : super(key: key); 37 | 38 | static Widget buildSimpleLoadingWidget(BuildContext context) { 39 | return SimpleLoading(height: 200); 40 | } 41 | 42 | static Widget buildSimpleFailedWidget( 43 | BuildContext context, ErrorResult result) { 44 | return SimpleFailed( 45 | message: result.error.toString(), 46 | retry: () { 47 | Loader.of(context)!.refresh(); 48 | }, 49 | ); 50 | } 51 | 52 | final FutureOr? initialData; 53 | 54 | ///task to load 55 | ///returned future'data will send by [LoaderWidgetBuilder] 56 | final Future> Function() loadTask; 57 | 58 | final LoaderWidgetBuilder builder; 59 | 60 | final Widget Function(BuildContext context, ErrorResult result)? errorBuilder; 61 | 62 | ///callback to handle error, could be null 63 | /// 64 | /// if null, will do nothing when an error occurred in [loadTask] 65 | final void Function(BuildContext context, ErrorResult result) onError; 66 | 67 | ///widget display when loading 68 | ///if null ,default to display a white background with a Circle Progress 69 | final WidgetBuilder? loadingBuilder; 70 | 71 | static LoaderState? of(BuildContext context) { 72 | return context.findAncestorStateOfType() as LoaderState?; 73 | } 74 | 75 | @override 76 | State createState() => LoaderState(); 77 | } 78 | 79 | @visibleForTesting 80 | const defaultErrorMessage = '啊哦,出错了~'; 81 | 82 | class LoaderState extends State { 83 | bool get isLoading => _loadingTask != null; 84 | 85 | CancelableOperation>? _loadingTask; 86 | 87 | Result? value; 88 | 89 | @override 90 | void initState() { 91 | super.initState(); 92 | if (widget.initialData != null) { 93 | scheduleMicrotask(() async { 94 | final data = await widget.initialData; 95 | if (data != null) { 96 | await _loadData(Future.value(Result.value(data)), force: true); 97 | } 98 | await refresh(); 99 | }); 100 | } else { 101 | refresh(); 102 | } 103 | } 104 | 105 | @override 106 | Loader get widget => super.widget as Loader; 107 | 108 | ///refresh data 109 | ///force: true to force refresh when a loading ongoing 110 | Future refresh({bool force: false}) async { 111 | await _loadData(widget.loadTask(), force: false); 112 | } 113 | 114 | Future> _loadData(Future> future, {bool force = false}) { 115 | if (_loadingTask != null && !force) { 116 | return _loadingTask!.value; 117 | } 118 | _loadingTask?.cancel(); 119 | _loadingTask = CancelableOperation>.fromFuture(future) 120 | ..value.then((result) { 121 | if (result.isError) { 122 | _onError(result as ErrorResult); 123 | } else { 124 | value = result; 125 | } 126 | }).catchError((e, StackTrace stack) { 127 | _onError(Result.error(e, stack) as ErrorResult); 128 | }).whenComplete(() { 129 | _loadingTask = null; 130 | setState(() {}); 131 | }); 132 | //notify if should be in loading status 133 | setState(() {}); 134 | return _loadingTask!.value; 135 | } 136 | 137 | void _onError(ErrorResult result) { 138 | debugPrint(result.stackTrace.toString()); 139 | 140 | if (value == null || value!.isError) { 141 | value = result; 142 | } 143 | widget.onError(context, result); 144 | } 145 | 146 | @override 147 | void dispose() { 148 | super.dispose(); 149 | _loadingTask?.cancel(); 150 | _loadingTask = null; 151 | } 152 | 153 | @override 154 | Widget build(BuildContext context) { 155 | if (value != null) { 156 | return LoaderResultWidget( 157 | result: value!, 158 | valueBuilder: widget.builder, 159 | errorBuilder: widget.errorBuilder ?? Loader.buildSimpleFailedWidget); 160 | } 161 | return (widget.loadingBuilder ?? Loader.buildSimpleLoadingWidget)(context); 162 | } 163 | } 164 | 165 | @visibleForTesting 166 | class LoaderResultWidget extends StatelessWidget { 167 | final Result result; 168 | 169 | final LoaderWidgetBuilder valueBuilder; 170 | final Widget Function(BuildContext context, ErrorResult result) errorBuilder; 171 | 172 | const LoaderResultWidget( 173 | {Key? key, 174 | required this.result, 175 | required this.valueBuilder, 176 | required this.errorBuilder}) 177 | : super(key: key); 178 | 179 | @override 180 | Widget build(BuildContext context) { 181 | if (result.isValue) { 182 | return valueBuilder(context, result.asValue!.value); 183 | } else { 184 | return errorBuilder(context, result as ErrorResult); 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /lib/src/auto_loader.dart: -------------------------------------------------------------------------------- 1 | part of '../loader.dart'; 2 | 3 | enum LoaderType { loading, error, empty } 4 | 5 | mixin AutoLoadMoreMixin on Model { 6 | ///the data loaded by [loadData] 7 | final List data = []; 8 | 9 | bool _more = true; 10 | 11 | ///has more items 12 | bool get hasMore => _more; 13 | 14 | int _offset = 0; 15 | 16 | CancelableOperation? _autoLoadOperation; 17 | 18 | @protected 19 | Future>> loadData(int offset); 20 | 21 | int get offset => _offset; 22 | 23 | dynamic error; 24 | 25 | bool get loading => _autoLoadOperation != null; 26 | 27 | List get items { 28 | final items = List.from(data); 29 | if (error != null) { 30 | items.add(LoaderType.error); 31 | return items; 32 | } 33 | if (_autoLoadOperation != null) { 34 | items.add(LoaderType.loading); 35 | return items; 36 | } 37 | 38 | if (items.isEmpty) { 39 | return const [LoaderType.empty]; 40 | } 41 | return items; 42 | } 43 | 44 | int get size => items.length; 45 | 46 | void refresh() { 47 | error = null; 48 | data.clear(); 49 | _offset = 0; 50 | _autoLoadOperation?.cancel(); 51 | _autoLoadOperation = null; 52 | loadMore(); 53 | } 54 | 55 | /// 56 | /// load more items 57 | /// 58 | /// [notification] use notification to check is need load more items 59 | /// 60 | void loadMore({ScrollEndNotification? notification}) { 61 | if (error != null) { 62 | return; 63 | } 64 | 65 | if (notification != null && 66 | (!_more || 67 | notification.metrics.extentAfter > 500 || 68 | _autoLoadOperation != null)) { 69 | return; 70 | } 71 | 72 | final offset = this.offset; 73 | _autoLoadOperation = 74 | CancelableOperation>>.fromFuture(loadData(offset)) 75 | ..value.then((r) { 76 | if (r.isError) { 77 | error = r.asError!.error.toString(); 78 | } else { 79 | final result = LoadMoreResult._from(r.asValue); 80 | _more = result.hasMore; 81 | _offset += result.loaded; 82 | data.addAll(result.value); 83 | } 84 | }).whenComplete(() { 85 | notifyListeners(); 86 | _autoLoadOperation = null; 87 | }); 88 | notifyListeners(); 89 | } 90 | 91 | ///create builder for [ListView] 92 | IndexedWidgetBuilder createBuilder(List data, 93 | {IndexedWidgetBuilder? builder}) { 94 | return (context, index) { 95 | final widget = buildItem(context, data, index) ?? 96 | (builder == null ? null : builder(context, index)); 97 | assert(widget != null, 'can not build ${data[index]}'); 98 | return widget!; 99 | }; 100 | } 101 | 102 | IndexedWidgetBuilder obtainBuilder() { 103 | return (context, index) { 104 | return buildItem(context, items, index)!; 105 | }; 106 | } 107 | 108 | ///build item for position [index] 109 | /// 110 | /// return null if you do not care this position 111 | /// 112 | @protected 113 | Widget? buildItem(BuildContext context, List list, int index) { 114 | if (list[index] == LoaderType.loading) { 115 | return buildLoadingItem(context, list.length == 1); 116 | } else if (list[index] == LoaderType.error) { 117 | return buildErrorItem(context, list.length == 1); 118 | } else if (list[index] == LoaderType.empty) { 119 | return buildEmptyWidget(context); 120 | } 121 | return null; 122 | } 123 | 124 | @protected 125 | Widget buildLoadingItem(BuildContext context, bool empty) { 126 | return SimpleLoading(height: empty ? 200 : 50); 127 | } 128 | 129 | @protected 130 | Widget buildErrorItem(BuildContext context, bool isEmpty) { 131 | final retry = () { 132 | error = null; 133 | loadMore(); 134 | }; 135 | if (isEmpty) { 136 | return SimpleFailed( 137 | retry: retry, 138 | message: error.toString(), 139 | ); 140 | } else { 141 | return Container( 142 | height: 56, 143 | child: Center( 144 | child: ElevatedButton( 145 | onPressed: retry, 146 | child: Text("加载失败!点击重试"), 147 | style: ButtonStyle( 148 | textStyle: MaterialStateProperty.all(TextStyle( 149 | color: Theme.of(context).primaryTextTheme.bodyText2!.color, 150 | )), 151 | backgroundColor: 152 | MaterialStateProperty.all(Theme.of(context).errorColor)), 153 | ), 154 | ), 155 | ); 156 | } 157 | } 158 | 159 | @protected 160 | Widget buildEmptyWidget(BuildContext context) { 161 | return Container( 162 | constraints: BoxConstraints(minHeight: 200), 163 | child: Center(child: Text('暂无数据...')), 164 | ); 165 | } 166 | } 167 | 168 | class LoadMoreResult extends ValueResult> { 169 | ///已加载的数据条目 170 | final int loaded; 171 | 172 | final bool hasMore; 173 | 174 | final dynamic payload; 175 | 176 | LoadMoreResult(List value, 177 | {int? loaded, this.hasMore = true, this.payload}) 178 | : this.loaded = loaded ?? value.length, 179 | super(value); 180 | 181 | factory LoadMoreResult._from(ValueResult>? result) { 182 | if (result is LoadMoreResult) { 183 | return result as LoadMoreResult; 184 | } 185 | return LoadMoreResult(result!.value); 186 | } 187 | 188 | ///utils method for result mapping 189 | static Result? map(Result result, R Function(T source) map) { 190 | if (result.isError) return result.asError; 191 | return Result.value(map(result.asValue!.value)); 192 | } 193 | } 194 | 195 | ///delegate to load more item 196 | ///[offset] loaded data length 197 | typedef LoadMoreDelegate = Future>> Function(int offset); 198 | -------------------------------------------------------------------------------- /lib/src/auto_loader_list.dart: -------------------------------------------------------------------------------- 1 | import 'package:loader/loader.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | import 'dart:async'; 5 | import 'package:async/async.dart'; 6 | import 'package:scoped_model/scoped_model.dart'; 7 | 8 | ///a list view 9 | ///auto load more when reached the bottom 10 | class AutoLoadMoreList extends StatefulWidget { 11 | ///return the items loaded 12 | ///null indicator failed 13 | /// 14 | ///NOTE: simply change [loadMore] will not change the list, 15 | ///you can update widget key to refresh delegate's changed 16 | final LoadMoreDelegate loadMore; 17 | 18 | ///build list tile with item 19 | final Widget Function(BuildContext context, T item) builder; 20 | 21 | const AutoLoadMoreList( 22 | {Key? key, required this.loadMore, required this.builder}) 23 | : super(key: key); 24 | 25 | @override 26 | _AutoLoadMoreListState createState() => _AutoLoadMoreListState(); 27 | } 28 | 29 | class _AutoLoadMoreList extends Model with AutoLoadMoreMixin { 30 | final LoadMoreDelegate delegate; 31 | 32 | _AutoLoadMoreList({required this.delegate}) { 33 | loadMore(); 34 | } 35 | 36 | @override 37 | Future>> loadData(int offset) { 38 | return delegate(offset); 39 | } 40 | } 41 | 42 | class _AutoLoadMoreListState extends State> { 43 | late _AutoLoadMoreList _autoLoader; 44 | 45 | @override 46 | void initState() { 47 | super.initState(); 48 | _autoLoader = _AutoLoadMoreList(delegate: widget.loadMore); 49 | _autoLoader.addListener(_onDataChanged); 50 | } 51 | 52 | @override 53 | void dispose() { 54 | _autoLoader.removeListener(_onDataChanged); 55 | super.dispose(); 56 | } 57 | 58 | void _onDataChanged() { 59 | setState(() {}); 60 | } 61 | 62 | @override 63 | Widget build(BuildContext context) { 64 | return NotificationListener( 65 | onNotification: (notification) { 66 | _autoLoader.loadMore(notification: notification); 67 | return false; 68 | }, 69 | child: ListView.builder( 70 | itemCount: _autoLoader.size, 71 | itemBuilder: _autoLoader.createBuilder(_autoLoader.items, 72 | builder: (context, index) { 73 | return widget.builder(context, _autoLoader.items[index]); 74 | })), 75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /lib/src/widgets.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class SimpleLoading extends StatelessWidget { 4 | final double height; 5 | 6 | const SimpleLoading({Key? key, this.height = 200}) : super(key: key); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return Container( 11 | constraints: BoxConstraints(minHeight: height), 12 | child: Center( 13 | child: CircularProgressIndicator(), 14 | ), 15 | ); 16 | } 17 | } 18 | 19 | class SimpleFailed extends StatelessWidget { 20 | final VoidCallback? retry; 21 | 22 | final String? message; 23 | 24 | const SimpleFailed({Key? key, this.retry, this.message}) : super(key: key); 25 | 26 | @override 27 | Widget build(BuildContext context) { 28 | return Container( 29 | constraints: BoxConstraints(minHeight: 200), 30 | child: Center( 31 | child: Column( 32 | mainAxisSize: MainAxisSize.min, 33 | children: [ 34 | message != null ? Text(message!) : Container(), 35 | SizedBox(height: 8), 36 | ElevatedButton( 37 | child: Text(MaterialLocalizations.of(context) 38 | .refreshIndicatorSemanticLabel), 39 | onPressed: retry, 40 | ) 41 | ], 42 | ), 43 | ), 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: loader 2 | description: state manager for loader 3 | version: 0.0.1 4 | author: boyan01 5 | homepage: https://github.com/boyan01/loader 6 | 7 | environment: 8 | sdk: '>=2.12.0 <3.0.0' 9 | 10 | dependencies: 11 | flutter: 12 | sdk: flutter 13 | scoped_model: ^2.0.0-nullsafety.0 14 | async: ^2.2.0 15 | 16 | dev_dependencies: 17 | flutter_test: 18 | sdk: flutter 19 | mockito: ^5.0.11 20 | 21 | flutter: 22 | -------------------------------------------------------------------------------- /test/loader_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:loader/loader.dart'; 4 | import 'package:mockito/mockito.dart'; 5 | 6 | const error_message = "on, what's going wrong"; 7 | 8 | const init_message = "i'am already here"; 9 | 10 | const loading_message = "i'am comming"; 11 | 12 | const succeed_message = "hello boyan!"; 13 | 14 | void main() { 15 | testWidgets('test load succeed with initial object', (tester) async { 16 | await tester.pumpWidget(_TestContext( 17 | child: Loader( 18 | initialData: init_message, 19 | builder: (context, content) { 20 | return Text(content); 21 | }, 22 | loadTask: () async { 23 | await Future.delayed(const Duration(milliseconds: 100)); 24 | return Result.value(succeed_message); 25 | }, 26 | ), 27 | )); 28 | await tester.pump(); 29 | 30 | expect(find.text(init_message), findsOneWidget); 31 | 32 | await tester.pump(const Duration(milliseconds: 120)); 33 | expect(find.text(init_message), findsNothing); 34 | expect(find.text(succeed_message), findsOneWidget); 35 | }); 36 | 37 | testWidgets("test load succeed", (tester) async { 38 | final failedCallback = _MockCallback(); 39 | await tester.pumpWidget(_TestContext( 40 | child: Loader( 41 | builder: (context, content) { 42 | return Text(content); 43 | }, 44 | loadingBuilder: (context) { 45 | return Text(loading_message); 46 | }, 47 | loadTask: () async { 48 | await Future.delayed(const Duration(milliseconds: 100)); 49 | return Result.value(succeed_message); 50 | }, 51 | onError: (context, result) { 52 | failedCallback.onCall(); 53 | }, 54 | ), 55 | )); 56 | await tester.pump(); 57 | 58 | expect(find.text(loading_message), findsOneWidget); 59 | 60 | await tester.pump(const Duration(milliseconds: 120)); 61 | expect(find.text(loading_message), findsNothing); 62 | expect(find.text(succeed_message), findsOneWidget); 63 | verifyNever(failedCallback.onCall()); 64 | }); 65 | 66 | testWidgets('test load failed with initial object', (tester) async { 67 | await tester.pumpWidget(_TestContext( 68 | child: Loader( 69 | initialData: init_message, 70 | builder: (context, content) { 71 | return Text(content); 72 | }, 73 | errorBuilder: (context, error) { 74 | return Text(error.error.toString()); 75 | }, 76 | loadTask: () async { 77 | await Future.delayed(const Duration(milliseconds: 100)); 78 | return Result.error(error_message); 79 | }, 80 | onError: (context, result) { 81 | //disable default error handle 82 | }, 83 | ), 84 | )); 85 | await tester.pump(); 86 | expect(find.text(init_message), findsOneWidget); 87 | 88 | await tester.pump(const Duration(milliseconds: 120)); 89 | expect(find.text(error_message), findsNothing); 90 | expect(find.text(init_message), findsOneWidget); 91 | }); 92 | 93 | testWidgets("test load failed", (tester) async { 94 | final failedCallback = _MockCallback(); 95 | await tester.pumpWidget(_TestContext( 96 | child: Loader( 97 | builder: (context, content) { 98 | return Text(content); 99 | }, 100 | errorBuilder: (context, error) { 101 | return Text(error.error.toString()); 102 | }, 103 | loadingBuilder: (context) { 104 | return Text(loading_message); 105 | }, 106 | loadTask: () async { 107 | await Future.delayed(const Duration(milliseconds: 100)); 108 | return Result.error(error_message); 109 | }, 110 | onError: (context, result) { 111 | failedCallback.onCall(); 112 | }, 113 | ), 114 | )); 115 | 116 | await tester.pump(); 117 | 118 | expect(find.text(loading_message), findsOneWidget); 119 | 120 | await tester.pump(const Duration(milliseconds: 120)); 121 | expect(find.text(loading_message), findsNothing); 122 | expect(find.text(error_message), findsOneWidget); 123 | 124 | verify(failedCallback.onCall()).called(1); 125 | }); 126 | } 127 | 128 | class _MockCallback extends Mock { 129 | void onCall(); 130 | } 131 | 132 | class _TestContext extends StatelessWidget { 133 | final Widget? child; 134 | 135 | const _TestContext({Key? key, this.child}) : super(key: key); 136 | 137 | @override 138 | Widget build(BuildContext context) { 139 | return MaterialApp(home: Scaffold(body: child)); 140 | } 141 | } 142 | --------------------------------------------------------------------------------