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

Flyclops / klipy_flutter / 21010856959

14 Jan 2026 09:40PM UTC coverage: 56.95%. First build
21010856959

push

github

web-flow
Merge pull request #2 from Flyclops/issues/1

Migrate from Tenor to KLIPY

107 of 147 new or added lines in 18 files covered. (72.79%)

295 of 518 relevant lines covered (56.95%)

1.04 hits per line

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

25.52
/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:klipy_flutter/src/components/components.dart';
8
import 'package:klipy_flutter/src/providers/app_bar_provider.dart';
9
import 'package:klipy_flutter/src/providers/tab_provider.dart';
10
import 'package:klipy_flutter/klipy_flutter.dart';
11

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

14
class KlipyTabViewStyle {
15
  final Color mediaBackgroundColor;
16

17
  const KlipyTabViewStyle({this.mediaBackgroundColor = Colors.white});
2✔
18
}
19

20
class KlipyTabView extends StatefulWidget {
21
  final Widget Function(BuildContext, Widget?)? builder;
22
  final KlipyCategoryStyle categoryStyle;
23
  final KlipyClient client;
24
  final String featuredCategory;
25
  final int gifsPerRow;
26
  final bool? keepAliveTabView;
27
  final Future<KlipyResponse?> Function(
28
    String queryText,
29
    String? pos,
30
    int limit,
31
    KlipyCategoryObject? category,
32
  )?
33
  onLoad;
34
  final Function(KlipyResultObject? gif)? onSelected;
35
  final bool showCategories;
36
  final KlipyTabViewStyle style;
37

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

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

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

62
  // Tab Provider
63
  late KlipyTabProvider _tabProvider;
64

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

68
  // AppBar Provider
69
  late KlipyAppBarProvider _appBarProvider;
70

71
  // Collection
72
  KlipyResponse? _collection;
73

74
  // List of gifs
75
  List<KlipyResultObject> _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<KlipyCategoryObject?> _categories = [];
90

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

94
  /// The current tabs data.
95
  late final KlipyTab 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<KlipyTab>();
3✔
108

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

112
    // AppBar Provider
113
    _appBarProvider = Provider.of<KlipyAppBarProvider>(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<KlipyTabProvider>(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),
×
NEW
168
                child: KlipyCategoryWidget(
×
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 `KlipyAttributionType.poweredBy` is disabled
186
            padding:
NEW
187
                _tabProvider.attributionType == KlipyAttributionType.poweredBy
×
188
                    ? EdgeInsets.zero
189
                    : const EdgeInsets.only(bottom: 0),
190
            scrollDirection: _scrollDirection,
×
191
          ),
192
        ),
193
      );
194
    }
195

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

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

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

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

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

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

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

291
      setState(() {
×
NEW
292
        _categories = fromKlipy;
×
293
      });
294
    } catch (e) {
295
      //
296
    }
297
  }
298

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

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

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

319
      _isLoading = true;
×
320

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

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

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

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

366
  // Return selected gif
NEW
367
  void _selectedGif(KlipyResultObject gif) {
×
368
    try {
369
      // https://docs.klipy.com/migrate-from-tenor/register-share
370
      client.registerShare(gif.id, search: _appBarProvider.queryText);
×
371
    } catch (e) {
372
      // do nothing if it fails
373
    }
374

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

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

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

396
  // When the text in the search input changes
397
  void _appBarProviderListener() {
×
398
    setState(() {
×
399
      _list = [];
×
400
      _collection = null;
×
401
      _hasMoreGifs = true;
×
402
    });
403

404
    _initialGifFetch();
×
405
  }
406

407
  /// When new tab is loaded into view
408
  void _tabProviderListener() {
×
409
    _initialGifFetch();
×
410
  }
411
}
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