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

Flyclops / tenor_flutter / 21024163227

15 Jan 2026 08:09AM UTC coverage: 57.57% (-0.5%) from 58.095%
21024163227

Pull #21

github

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

6 of 28 new or added lines in 3 files covered. (21.43%)

4 existing lines in 2 files now uncovered.

308 of 535 relevant lines covered (57.57%)

1.01 hits per line

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

23.87
/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
  )?
35
  onLoad;
36
  final Function(TenorResult? gif)? onSelected;
37
  final bool showCategories;
38
  final TenorTabViewStyle style;
39

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

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

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

64
  // Tab Provider
65
  late TenorTabProvider _tabProvider;
66

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

70
  // AppBar Provider
71
  late TenorAppBarProvider _appBarProvider;
72

73
  // Collection
74
  TenorResponse? _collection;
75

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

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

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

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

88
  // Offset
89
  String? offset;
90

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

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

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

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

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

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

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

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

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

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

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

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

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

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

202
    return Padding(
×
203
      padding: const EdgeInsets.symmetric(horizontal: 8.0),
204
      child: MasonryGridView.count(
×
205
        controller: _scrollController,
×
206
        shrinkWrap: true,
207
        crossAxisCount: widget.gifsPerRow,
×
208
        crossAxisSpacing: 8,
209
        keyboardDismissBehavior: _appBarProvider.keyboardDismissBehavior,
×
210
        itemBuilder:
NEW
211
            (ctx, idx) => ClipRRect(
×
NEW
212
              borderRadius: BorderRadius.circular(8),
×
NEW
213
              child: TenorSelectableGif(
×
NEW
214
                backgroundColor: widget.style.mediaBackgroundColor,
×
NEW
215
                onTap: (selectedResult) => _selectedGif(selectedResult),
×
NEW
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:
NEW
223
            _tabProvider.attributionType == TenorAttributionType.poweredBy
×
224
                ? null
NEW
225
                : EdgeInsets.only(
×
NEW
226
                  bottom: MediaQuery.of(context).padding.bottom,
×
227
                ),
UNCOV
228
        scrollDirection: _scrollDirection,
×
229
      ),
230
    );
231
  }
232

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

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

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

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

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

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

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

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

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

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

325
      _isLoading = true;
×
326

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

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

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

367
    if (fillScrollableArea && _scrollController.position.extentAfter == 0) {
×
368
      Future.microtask(() => _loadMore(fillScrollableArea: true));
×
369
    }
370
  }
371

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

381
    // return result to the consumer
382
    Navigator.pop(
×
383
      context,
×
384
      gif.copyWith(
×
385
        source: _tabProvider.selectedTab.name,
×
386
      ),
387
    );
388
  }
389

390
  // if you scroll within a threshhold of the bottom of the screen, load more gifs
391
  void _scrollControllerListener() {
×
392
    // trending-gifs, etc
393
    final customCategorySelected =
NEW
394
        _appBarProvider.selectedCategory != null &&
×
UNCOV
395
        _appBarProvider.queryText == '';
×
396

397
    if (customCategorySelected ||
398
        _appBarProvider.queryText != '' ||
×
399
        widget.showCategories == false) {
×
400
      if (_scrollController.positions.last.extentAfter.lessThan(500) &&
×
401
          !_isLoading) {
×
402
        _loadMore();
×
403
      }
404
    }
405
  }
406

407
  // When the text in the search input changes
408
  void _appBarProviderListener() {
×
NEW
409
    final queryText = _appBarProvider.queryText;
×
NEW
410
    final trimmedQueryText = _appBarProvider.queryText.trim();
×
NEW
411
    final trimmedPreviousQueryText = _appBarProvider.previousQueryText.trim();
×
412

413
    // do nothing if the text did not change
NEW
414
    if (trimmedQueryText == trimmedPreviousQueryText) return;
×
415

416
    // Prevent searches with only spaces
NEW
417
    if (queryText.isNotEmpty && trimmedQueryText.isEmpty) return;
×
418

419
    setState(() {
×
420
      _list = [];
×
421
      _collection = null;
×
422
      _hasMoreGifs = true;
×
423
    });
424

425
    _initialGifFetch();
×
426
  }
427

428
  /// When new tab is loaded into view
429
  void _tabProviderListener() {
×
430
    _initialGifFetch();
×
431
  }
432
}
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