• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Flyclops / tenor_flutter / 19153161071

06 Nov 2025 11:35PM UTC coverage: 57.656% (-3.0%) from 60.619%
19153161071

push

github

web-flow
Merge pull request #12 from Flyclops/scrolling-issue-WIP

Scrolling issue wip

33 of 90 new or added lines in 8 files covered. (36.67%)

4 existing lines in 2 files now uncovered.

305 of 529 relevant lines covered (57.66%)

1.01 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

25.0
/lib/src/components/tab_view.dart
1
// TODO: Not super happy with how categories exist in this file. Refactor in the future.
2
// ignore_for_file: implementation_imports
3
import 'package:extended_image/extended_image.dart';
4
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
5
import 'package:flutter/material.dart';
6
import 'package:provider/provider.dart';
7
import 'package:tenor_flutter/src/components/components.dart';
8
import 'package:tenor_flutter/src/providers/app_bar_provider.dart';
9
import 'package:tenor_flutter/src/providers/tab_provider.dart';
10
import 'package:tenor_flutter/tenor_flutter.dart';
11

12
const featuredCategoryPath = '##trending-gifs';
13

14
class TenorTabViewStyle {
15
  final Color mediaBackgroundColor;
16

17
  const TenorTabViewStyle({
1✔
18
    this.mediaBackgroundColor = Colors.white,
19
  });
20
}
21

22
class TenorTabView extends StatefulWidget {
23
  final Widget Function(BuildContext, Widget?)? builder;
24
  final TenorCategoryStyle categoryStyle;
25
  final Tenor client;
26
  final String featuredCategory;
27
  final int gifsPerRow;
28
  final bool? keepAliveTabView;
29
  final Future<TenorResponse?> Function(
30
    String queryText,
31
    String? pos,
32
    int limit,
33
    TenorCategory? category,
34
  )? onLoad;
35
  final Function(TenorResult? gif)? onSelected;
36
  final bool showCategories;
37
  final TenorTabViewStyle style;
38

39
  const TenorTabView({
1✔
40
    required this.client,
41
    this.builder,
42
    this.categoryStyle = const TenorCategoryStyle(),
43
    String? featuredCategory,
44
    int? gifsPerRow,
45
    this.keepAliveTabView,
46
    this.onLoad,
47
    this.onSelected,
48
    this.showCategories = false,
49
    this.style = const TenorTabViewStyle(),
50
    super.key,
51
  })  : featuredCategory = featuredCategory ?? '📈 Featured',
52
        gifsPerRow = gifsPerRow ?? 3;
53

54
  @override
1✔
55
  State<TenorTabView> createState() => _TenorTabViewState();
1✔
56
}
57

58
class _TenorTabViewState extends State<TenorTabView>
59
    with AutomaticKeepAliveClientMixin {
60
  @override
1✔
61
  bool get wantKeepAlive => widget.keepAliveTabView ?? true;
2✔
62

63
  // Tab Provider
64
  late TenorTabProvider _tabProvider;
65

66
  // Scroll Controller
67
  late final ScrollController _scrollController;
68

69
  // AppBar Provider
70
  late TenorAppBarProvider _appBarProvider;
71

72
  // Collection
73
  TenorResponse? _collection;
74

75
  // List of gifs
76
  List<TenorResult> _list = [];
77

78
  // Direction
79
  final Axis _scrollDirection = Axis.vertical;
80

81
  // State to tell us if we are currently loading gifs.
82
  bool _isLoading = false;
83

84
  /// State to tell us if we can request more gifs.
85
  bool _hasMoreGifs = true;
86

87
  // Offset
88
  String? offset;
89

90
  List<TenorCategory?> _categories = [];
91

92
  /// The Tenor client so we can use the API.
93
  late final Tenor client;
94

95
  /// The current tabs data.
96
  late final TenorTab tab;
97

98
  /// The limit of gifs to request per load.
99
  late int requestLimit;
100

101
  @override
1✔
102
  void initState() {
103
    super.initState();
1✔
104
    // Setup client
105
    client = widget.client;
3✔
106

107
    // Which tab are we?
108
    tab = context.read<TenorTab>();
3✔
109

110
    // We should update this whenever the size changes eventually
111
    requestLimit = _calculateLimit();
2✔
112

113
    // AppBar Provider
114
    _appBarProvider = Provider.of<TenorAppBarProvider>(context, listen: false);
3✔
115
    _appBarProvider.addListener(_appBarProviderListener);
3✔
116

117
    // Scroll Controller
118
    _scrollController = ScrollController();
2✔
119
    _scrollController.addListener(_scrollControllerListener);
3✔
120

121
    // Tab Provider
122
    _tabProvider = Provider.of<TenorTabProvider>(context, listen: false);
3✔
123
    _tabProvider.addListener(_tabProviderListener);
3✔
124

125
    WidgetsBinding.instance.addPostFrameCallback((_) {
3✔
126
      // load categories
127
      if (widget.showCategories) {
2✔
128
        _loadCatagories();
1✔
129
      }
130
      // load gifs if the query text starts populated or if show categories is disabled
131
      if (_appBarProvider.queryText != '' || widget.showCategories == false) {
6✔
NEW
132
        _initialGifFetch();
×
133
      }
134
    });
135
  }
136

137
  @override
1✔
138
  void dispose() {
139
    _appBarProvider.removeListener(_appBarProviderListener);
3✔
140
    _scrollController.removeListener(_scrollControllerListener);
3✔
141
    _tabProvider.removeListener(_tabProviderListener);
3✔
142
    super.dispose();
1✔
143
  }
144

145
  @override
1✔
146
  Widget build(BuildContext context) {
147
    super.build(context);
1✔
148
    if (_list.isEmpty && _categories.isEmpty) {
4✔
149
      return const Center(
150
        child: CircularProgressIndicator(),
151
      );
152
    }
153

154
    if (_appBarProvider.queryText.isEmpty &&
×
155
        _appBarProvider.selectedCategory == null &&
×
156
        widget.showCategories) {
×
157
      return Padding(
×
158
        padding: const EdgeInsets.symmetric(horizontal: 8.0),
159
        child: ClipRRect(
×
160
          borderRadius: BorderRadius.circular(8),
×
161
          child: MasonryGridView.count(
×
162
            shrinkWrap: true,
NEW
163
            controller: _scrollController,
×
NEW
164
            crossAxisCount: widget.gifsPerRow,
×
165
            crossAxisSpacing: 8,
166
            keyboardDismissBehavior: _appBarProvider.keyboardDismissBehavior,
×
167
            itemBuilder: (ctx, idx) {
×
168
              final category = _categories[idx];
×
169
              return ClipRRect(
×
170
                borderRadius: BorderRadius.circular(8),
×
171
                child: TenorCategoryWidget(
×
172
                  style: widget.categoryStyle,
×
173
                  category: category,
174
                  onTap: (selectedCategory) {
×
UNCOV
175
                    if (selectedCategory.path.startsWith('##') == false) {
×
176
                      // if it's a normal category, search it up
UNCOV
177
                      _appBarProvider.queryText = selectedCategory.searchTerm;
×
178
                    } else {
179
                      // otherwise just set it so we can make a custom view
NEW
180
                      _appBarProvider.selectedCategory = selectedCategory;
×
181
                    }
182
                  },
183
                ),
184
              );
185
            },
186
            itemCount: _categories.length,
×
187
            mainAxisSpacing: 8,
188
            // Add safe area padding if `TenorAttributionType.poweredBy` is disabled
189
            padding:
190
                _tabProvider.attributionType == TenorAttributionType.poweredBy
×
191
                    ? null
192
                    : EdgeInsets.only(
×
193
                        bottom: MediaQuery.of(context).padding.bottom,
×
194
                      ),
195
            scrollDirection: _scrollDirection,
×
196
          ),
197
        ),
198
      );
199
    }
200

201
    return Padding(
×
202
      padding: const EdgeInsets.symmetric(horizontal: 8.0),
203
      child: MasonryGridView.count(
×
NEW
204
        controller: _scrollController,
×
205
        shrinkWrap: true,
NEW
206
        crossAxisCount: widget.gifsPerRow,
×
207
        crossAxisSpacing: 8,
208
        keyboardDismissBehavior: _appBarProvider.keyboardDismissBehavior,
×
209
        itemBuilder: (ctx, idx) => ClipRRect(
×
210
          borderRadius: BorderRadius.circular(8),
×
211
          child: TenorSelectableGif(
×
212
            backgroundColor: widget.style.mediaBackgroundColor,
×
213
            onTap: (selectedResult) => _selectedGif(
×
214
              selectedResult,
215
            ),
216
            result: _list[idx],
×
217
          ),
218
        ),
219
        itemCount: _list.length,
×
220
        mainAxisSpacing: 8,
221
        // Add safe area padding if `TenorAttributionType.poweredBy` is disabled
222
        padding: _tabProvider.attributionType == TenorAttributionType.poweredBy
×
223
            ? null
224
            : EdgeInsets.only(
×
225
                bottom: MediaQuery.of(context).padding.bottom,
×
226
              ),
227
        scrollDirection: _scrollDirection,
×
228
      ),
229
    );
230
  }
231

232
  // Estimate the request limit based on the visible area. Doesn't need to be precise.
233
  // This function assumes all gifs are 1:1 aspect ratio for simplicity.
234
  int _calculateLimit() {
1✔
235
    // Tenor has a hard limit of 50 per request
236
    const int tenorRequestLimit = 50;
237
    // Call this here so we get updated constraints in case of size change
238
    final constraints = context.read<BoxConstraints>();
2✔
239
    // The width of each gif (estimated)
240
    final gifWidth = constraints.maxWidth / widget.gifsPerRow;
4✔
241
    // How many rows of gifs can fit on the screen (estimated)
242
    final gifRowCount = (constraints.maxHeight / gifWidth).round();
3✔
243
    // Based on estimates, how many gifs can we fit into the TabView
244
    final calculatedRequestLimit = widget.gifsPerRow * gifRowCount;
3✔
245
    // If the limit is greater than Tenor's hard limit, cap it
246
    return calculatedRequestLimit > tenorRequestLimit
1✔
247
        ? tenorRequestLimit
248
        : calculatedRequestLimit;
249
  }
250

251
  // Load an initial batch of gifs and then attempt to load more until the list
252
  // is full by checking if there is a scrollable area. The reason we are loading
253
  // like this and not with a predictive method that calculates based on size is
254
  // because iOS "Display Zoom" breaks that.
NEW
255
  Future<void> _initialGifFetch() async {
×
256
    // Prevent non active tabs from loading more
NEW
257
    if (_tabProvider.selectedTab != tab) return;
×
258

259
    // Do not fetch when categories are visible
NEW
260
    if (widget.showCategories &&
×
NEW
261
        _appBarProvider.queryText.isEmpty &&
×
NEW
262
        _appBarProvider.selectedCategory == null) {
×
263
      return;
264
    }
265

266
    // Load some gifs so that the ScrollController can become attached
NEW
267
    if (_list.isEmpty) {
×
NEW
268
      await _loadMore();
×
269
    }
270

271
    // Wait for a frame so that we can ensure that `scrollController` is attached
NEW
272
    WidgetsBinding.instance.addPostFrameCallback((_) async {
×
NEW
273
      while (_scrollController.position.extentAfter == 0) {
×
NEW
274
        await _loadMore();
×
275
      }
276
    });
277
  }
278

279
  Future<void> _loadCatagories() async {
1✔
280
    try {
281
      final fromTenor = await client.categories();
2✔
282
      final featuredGifResponse = await client.featured(limit: 1);
×
283
      final featuredGif = featuredGifResponse?.results.first;
×
284
      if (featuredGif != null) {
285
        fromTenor.insert(
×
286
          0,
287
          TenorCategory(
×
288
            image: featuredGif.media.tinyGif?.url ?? '',
×
289
            name: widget.featuredCategory,
×
290
            path: featuredCategoryPath,
291
            searchTerm: 'featured',
292
          ),
293
        );
294
      }
295

296
      setState(() {
×
297
        _categories = fromTenor;
×
298
      });
299
    } catch (e) {
300
      //
301
    }
302
  }
303

304
  Future<void> _loadMore() async {
×
305
    // 1 - prevent non active tabs from loading more
306
    // 2 - if it's loading don't load more
307
    // 3 - if there are no more gifs to load, don't load more
NEW
308
    if (_tabProvider.selectedTab != tab || _isLoading || !_hasMoreGifs) return;
×
309

310
    try {
311
      // fail safe if categories are empty when we load more (network issues)
312
      if (widget.showCategories && _categories.isEmpty) {
×
313
        _loadCatagories();
×
314
      }
315

316
      // api says there are no more gifs, so lets stop requesting
317
      if (_collection?.next == '') {
×
NEW
318
        setState(() {
×
NEW
319
          _hasMoreGifs = false;
×
320
        });
321
        return;
322
      }
323

324
      _isLoading = true;
×
325

326
      // Offset pagination for query
327
      if (_collection == null) {
×
328
        offset = null;
×
329
      } else {
UNCOV
330
        offset = _collection!.next;
×
331
      }
332

333
      if (widget.onLoad != null) {
×
334
        final response = await widget.onLoad?.call(
×
335
          _appBarProvider.queryText,
×
336
          offset,
×
NEW
337
          requestLimit,
×
338
          _appBarProvider.selectedCategory,
×
339
        );
340
        if (response != null) {
341
          _collection = response;
×
342
        }
343
      }
344

345
      // Set result to list
346
      if (_collection != null && _collection!.results.isNotEmpty && mounted) {
×
347
        setState(() {
×
348
          _list.addAll(_collection!.results);
×
349
          _isLoading = false;
×
350
        });
351
      } else {
352
        // so it refreshes on something like categories
353
        setState(() {
×
354
          _isLoading = false;
×
355
        });
356
      }
357
    } on TenorNetworkException {
×
358
      _isLoading = false;
×
359
    } on TenorApiException {
×
360
      _isLoading = false;
×
361
    } catch (e) {
362
      _isLoading = false;
×
363
      rethrow;
364
    }
365
  }
366

367
  // Return selected gif
368
  void _selectedGif(TenorResult gif) {
×
369
    try {
370
      // https://developers.google.com/tenor/guides/endpoints#register-share
371
      client.registerShare(gif.id, search: _appBarProvider.queryText);
×
372
    } catch (e) {
373
      // do nothing if it fails
374
    }
375

376
    // return result to the consumer
377
    Navigator.pop(
×
378
      context,
×
379
      gif.copyWith(
×
NEW
380
        source: _tabProvider.selectedTab.name,
×
381
      ),
382
    );
383
  }
384

385
  // if you scroll within a threshhold of the bottom of the screen, load more gifs
NEW
386
  void _scrollControllerListener() {
×
387
    // trending-gifs, etc
NEW
388
    final customCategorySelected = _appBarProvider.selectedCategory != null &&
×
NEW
389
        _appBarProvider.queryText == '';
×
390

391
    if (customCategorySelected ||
NEW
392
        _appBarProvider.queryText != '' ||
×
NEW
393
        widget.showCategories == false) {
×
NEW
394
      if (_scrollController.positions.last.extentAfter.lessThan(500) &&
×
NEW
395
          !_isLoading) {
×
NEW
396
        _loadMore();
×
397
      }
398
    }
399
  }
400

401
  // When the text in the search input changes
NEW
402
  void _appBarProviderListener() {
×
NEW
403
    setState(() {
×
NEW
404
      _list = [];
×
NEW
405
      _collection = null;
×
NEW
406
      _hasMoreGifs = true;
×
407
    });
408

NEW
409
    _initialGifFetch();
×
410
  }
411

412
  /// When new tab is loaded into view
NEW
413
  void _tabProviderListener() {
×
NEW
414
    _initialGifFetch();
×
415
  }
416
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc