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

Flyclops / tenor_flutter / 19694531205

26 Nov 2025 06:21AM UTC coverage: 57.547% (-0.1%) from 57.656%
19694531205

Pull #15

github

web-flow
Merge c16cd488e into 1de5d44d7
Pull Request #15: Fix freezing and crashing

0 of 1 new or added line in 1 file covered. (0.0%)

1 existing line in 1 file now uncovered.

305 of 530 relevant lines covered (57.55%)

1.01 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({
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✔
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,
163
            controller: _scrollController,
×
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) {
×
175
                    if (selectedCategory.path.startsWith('##') == false) {
×
176
                      // if it's a normal category, search it up
177
                      _appBarProvider.queryText = selectedCategory.searchTerm;
×
178
                    } else {
179
                      // otherwise just set it so we can make a custom view
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(
×
204
        controller: _scrollController,
×
205
        shrinkWrap: true,
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.
255
  Future<void> _initialGifFetch() async {
×
256
    // Prevent non active tabs from loading more
257
    if (_tabProvider.selectedTab != tab) return;
×
258

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

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

271
    // Wait for a frame so that we can ensure that `scrollController` is attached
272
    WidgetsBinding.instance.addPostFrameCallback((_) async {
×
273
      while (_scrollController.position.extentAfter == 0) {
×
274
        // Stop trying to load more and exit the loop if:
275
        // 1 - the selected tab has changed
276
        // 2 - there are no more gifs to load
NEW
277
        if (_tabProvider.selectedTab != tab || !_hasMoreGifs) return;
×
278

UNCOV
279
        await _loadMore();
×
280
      }
281
    });
282
  }
283

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

301
      setState(() {
×
302
        _categories = fromTenor;
×
303
      });
304
    } catch (e) {
305
      //
306
    }
307
  }
308

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

315
    try {
316
      // fail safe if categories are empty when we load more (network issues)
317
      if (widget.showCategories && _categories.isEmpty) {
×
318
        _loadCatagories();
×
319
      }
320

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

329
      _isLoading = true;
×
330

331
      // Offset pagination for query
332
      if (_collection == null) {
×
333
        offset = null;
×
334
      } else {
335
        offset = _collection!.next;
×
336
      }
337

338
      if (widget.onLoad != null) {
×
339
        final response = await widget.onLoad?.call(
×
340
          _appBarProvider.queryText,
×
341
          offset,
×
342
          requestLimit,
×
343
          _appBarProvider.selectedCategory,
×
344
        );
345
        if (response != null) {
346
          _collection = response;
×
347
        }
348
      }
349

350
      // Set result to list
351
      if (_collection != null && _collection!.results.isNotEmpty && mounted) {
×
352
        setState(() {
×
353
          _list.addAll(_collection!.results);
×
354
          _isLoading = false;
×
355
        });
356
      } else {
357
        // so it refreshes on something like categories
358
        setState(() {
×
359
          _isLoading = false;
×
360
        });
361
      }
362
    } on TenorNetworkException {
×
363
      _isLoading = false;
×
364
    } on TenorApiException {
×
365
      _isLoading = false;
×
366
    } catch (e) {
367
      _isLoading = false;
×
368
      rethrow;
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 = _appBarProvider.selectedCategory != null &&
×
394
        _appBarProvider.queryText == '';
×
395

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

406
  // When the text in the search input changes
407
  void _appBarProviderListener() {
×
408
    setState(() {
×
409
      _list = [];
×
410
      _collection = null;
×
411
      _hasMoreGifs = true;
×
412
    });
413

414
    _initialGifFetch();
×
415
  }
416

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