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

Flyclops / tenor_flutter / 21023103636

15 Jan 2026 07:23AM UTC coverage: 58.015% (-0.08%) from 58.095%
21023103636

Pull #21

github

web-flow
Merge 525b3f158 into 9f432f3f7
Pull Request #21: End of life bug fixes

6 of 23 new or added lines in 3 files covered. (26.09%)

4 existing lines in 2 files now uncovered.

304 of 524 relevant lines covered (58.02%)

1.04 hits per line

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

24.83
/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({this.mediaBackgroundColor = Colors.white});
2✔
18
}
19

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

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

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

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

62
  // Tab Provider
63
  late TenorTabProvider _tabProvider;
64

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

68
  // AppBar Provider
69
  late TenorAppBarProvider _appBarProvider;
70

71
  // Collection
72
  TenorResponse? _collection;
73

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

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

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

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

86
  // Offset
87
  String? offset;
88

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

263
    // Load some gifs so that the ScrollController can become attached
264
    if (_list.isEmpty) {
×
265
      await _loadMore();
×
266
    }
267

268
    // Wait for a frame so that we can ensure that `scrollController` is attached
269
    WidgetsBinding.instance.addPostFrameCallback((_) async {
×
270
      if (_scrollController.position.extentAfter == 0) {
×
271
        _loadMore(fillScrollableArea: true);
×
272
      }
273
    });
274
  }
275

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

293
      setState(() {
×
294
        _categories = fromTenor;
×
295
      });
296
    } catch (e) {
297
      //
298
    }
299
  }
300

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

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

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

321
      _isLoading = true;
×
322

323
      // Offset pagination for query
324
      if (_collection == null) {
×
325
        offset = null;
×
326
      } else {
327
        offset = _collection!.next;
×
328
      }
329

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

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

363
    if (fillScrollableArea && _scrollController.position.extentAfter == 0) {
×
364
      Future.microtask(() => _loadMore(fillScrollableArea: true));
×
365
    }
366
  }
367

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

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

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

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

398
  // When the text in the search input changes
399
  void _appBarProviderListener() {
×
400
    // Prevent searches with only spaces
NEW
401
    if (_appBarProvider.queryText.isNotEmpty &&
×
NEW
402
        _appBarProvider.queryText.trim().isEmpty) {
×
403
      return;
404
    }
405
    setState(() {
×
406
      _list = [];
×
407
      _collection = null;
×
408
      _hasMoreGifs = true;
×
409
    });
410

411
    _initialGifFetch();
×
412
  }
413

414
  /// When new tab is loaded into view
415
  void _tabProviderListener() {
×
416
    _initialGifFetch();
×
417
  }
418
}
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