giphy_flutter 0.1.0 copy "giphy_flutter: ^0.1.0" to clipboard
giphy_flutter: ^0.1.0 copied to clipboard

A production-ready Flutter SDK and UI package for GIPHY integration.

example/lib/main.dart

// The giphy_flutter example: a chat thread you can drop a GIF into.
//
// The picker's natural habitat is a conversation, so that is what this app is.
// Every GIF you pick is filed into the thread as a strip of footage with a
// slate caption carrying its GIPHY title — which is usually absurd, and worth
// showing.
//
// Run it with your own key, either by typing it on the first screen or by
// skipping that screen entirely:
//
//   flutter run --dart-define=GIPHY_API_KEY=your_key

import 'dart:math' as math;

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:giphy_flutter/giphy_flutter.dart';

void main() => runApp(const ReactionDeskApp());

// ---------------------------------------------------------------------------
// Design tokens
// ---------------------------------------------------------------------------

/// The app field: the pale institutional green of a photo-processing envelope.
const Color _ground = Color(0xFFDCE2D6);

/// Type, and the fill of your own messages.
const Color _ink = Color(0xFF17191B);

/// Incoming messages and the compose bar.
const Color _paper = Color(0xFFFBFBF8);

/// The only accent. Reserved for the GIF affordance and the slate strip, so
/// that picking a GIF is the brightest thing on screen.
const Color _marigold = Color(0xFFE4A02C);

/// Failure states.
const Color _brick = Color(0xFFA6412E);

/// Timestamps, metadata, and anything secondary.
const Color _graphite = Color(0xFF6E7469);

/// Utility face for slates, labels and timestamps: mono, small, wide-tracked.
///
/// The platform sans carries the conversation; this carries the filing.
TextStyle _mono({
  double size = 10,
  Color color = _graphite,
  FontWeight weight = FontWeight.w600,
}) {
  return TextStyle(
    fontFamily: 'monospace',
    fontSize: size,
    height: 1.2,
    letterSpacing: 1.2,
    fontWeight: weight,
    color: color,
  );
}

/// The picker, tuned to this app's palette rather than left on its defaults.
///
/// Deriving from [GiphyTheme.light] is also the honest choice here: the light
/// theme is the one whose attribution used to be invisible.
final GiphyTheme _pickerTheme = GiphyTheme.light.copyWith(
  backgroundColor: _paper,
  searchBarColor: const Color(0xFFEDF0E9),
  searchTextStyle: const TextStyle(color: _ink, fontSize: 16),
  hintStyle: const TextStyle(color: _graphite, fontSize: 16),
  handleColor: _graphite,
  loadingIndicatorColor: _ink,
  attributionStyle: _mono(color: _graphite, weight: FontWeight.w700),
  errorColor: _brick,
);

/// Copy for the picker, in this app's voice.
const GiphyLabels _pickerLabels = GiphyLabels(
  searchHint: 'Find a reaction',
  noResults: 'Nothing matched. Try a shorter word.',
);

// ---------------------------------------------------------------------------
// App shell
// ---------------------------------------------------------------------------

/// Root of the example. Holds the API key for the life of the session.
class ReactionDeskApp extends StatefulWidget {
  /// Creates the example app.
  const ReactionDeskApp({super.key});

  @override
  State<ReactionDeskApp> createState() => _ReactionDeskAppState();
}

class _ReactionDeskAppState extends State<ReactionDeskApp> {
  /// Set with `--dart-define=GIPHY_API_KEY=...` to skip the setup screen.
  static const String _envKey = String.fromEnvironment('GIPHY_API_KEY');

  String? _apiKey = _envKey.isEmpty ? null : _envKey;

  @override
  Widget build(BuildContext context) {
    final key = _apiKey;

    return MaterialApp(
      title: 'giphy_flutter',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        scaffoldBackgroundColor: _ground,
        colorScheme: ColorScheme.fromSeed(seedColor: _marigold).copyWith(
          primary: _ink,
          onPrimary: _paper,
          secondary: _marigold,
          surface: _paper,
          onSurface: _ink,
          error: _brick,
          onError: _paper,
        ),
        textTheme: const TextTheme(
          bodyMedium: TextStyle(fontSize: 15, height: 1.35, color: _ink),
        ),
      ),
      home: key == null
          ? _SetupScreen(
              prefill: _envKey,
              onSubmit: (value) => setState(() => _apiKey = value),
            )
          : _ThreadScreen(
              apiKey: key,
              onChangeKey: () => setState(() => _apiKey = null),
            ),
    );
  }
}

// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------

/// Asks for a GIPHY API key so the example runs from a plain `flutter run`.
class _SetupScreen extends StatefulWidget {
  const _SetupScreen({required this.prefill, required this.onSubmit});

  final String prefill;
  final ValueChanged<String> onSubmit;

  @override
  State<_SetupScreen> createState() => _SetupScreenState();
}

class _SetupScreenState extends State<_SetupScreen> {
  late final TextEditingController _field =
      TextEditingController(text: widget.prefill);
  String? _error;

  @override
  void dispose() {
    _field.dispose();
    super.dispose();
  }

  void _submit() {
    final value = _field.text.trim();
    if (value.isEmpty) {
      setState(() => _error = 'Enter your key to continue.');
      return;
    }
    widget.onSubmit(value);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Center(
          child: SingleChildScrollView(
            padding: const EdgeInsets.all(28),
            child: ConstrainedBox(
              constraints: const BoxConstraints(maxWidth: 420),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text('SETUP', style: _mono(weight: FontWeight.w700)),
                  const SizedBox(height: 14),
                  const Text(
                    'Add a GIPHY API key',
                    style: TextStyle(
                      fontSize: 30,
                      height: 1.1,
                      fontWeight: FontWeight.w800,
                      letterSpacing: -0.6,
                      color: _ink,
                    ),
                  ),
                  const SizedBox(height: 12),
                  const Text(
                    'The example talks to GIPHY directly, so it needs a key of '
                    'your own. Create one at developers.giphy.com — the free '
                    'tier is plenty.',
                    style: TextStyle(fontSize: 15, height: 1.4, color: _ink),
                  ),
                  const SizedBox(height: 28),
                  Text('API KEY', style: _mono()),
                  const SizedBox(height: 8),
                  DecoratedBox(
                    decoration: BoxDecoration(
                      color: _paper,
                      borderRadius: BorderRadius.circular(10),
                      border: Border.all(
                        color: _error == null ? const Color(0xFFC9D0C2) : _brick,
                      ),
                    ),
                    child: Padding(
                      padding: const EdgeInsets.symmetric(
                        horizontal: 14,
                        vertical: 4,
                      ),
                      child: TextField(
                        controller: _field,
                        autofocus: true,
                        autocorrect: false,
                        enableSuggestions: false,
                        textInputAction: TextInputAction.go,
                        onSubmitted: (_) => _submit(),
                        style: _mono(size: 14, color: _ink),
                        cursorColor: _ink,
                        decoration: InputDecoration(
                          border: InputBorder.none,
                          isDense: true,
                          contentPadding: const EdgeInsets.symmetric(
                            vertical: 14,
                          ),
                          hintText: 'dc6zaTOxFJmzC',
                          hintStyle: _mono(size: 14),
                        ),
                      ),
                    ),
                  ),
                  if (_error != null) ...[
                    const SizedBox(height: 8),
                    Text(_error!, style: _mono(color: _brick)),
                  ],
                  const SizedBox(height: 20),
                  SizedBox(
                    width: double.infinity,
                    child: FilledButton(
                      onPressed: _submit,
                      style: FilledButton.styleFrom(
                        backgroundColor: _ink,
                        foregroundColor: _paper,
                        padding: const EdgeInsets.symmetric(vertical: 16),
                        shape: RoundedRectangleBorder(
                          borderRadius: BorderRadius.circular(10),
                        ),
                      ),
                      child: const Text(
                        'Start the thread',
                        style: TextStyle(
                          fontSize: 15,
                          fontWeight: FontWeight.w700,
                        ),
                      ),
                    ),
                  ),
                  const SizedBox(height: 18),
                  Text(
                    'OR RUN WITH  --DART-DEFINE=GIPHY_API_KEY=…',
                    style: _mono(size: 9),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Thread
// ---------------------------------------------------------------------------

/// One message in the thread. Sealed so the list builder has to handle both
/// kinds.
sealed class _Message {
  _Message({required this.mine, required this.at, this.animate = false});

  /// Whether you sent it, which decides side and colour.
  final bool mine;

  /// Send time, shown on the bubble.
  final DateTime at;

  /// Whether to play the arrival reveal. False for the seeded backlog, so the
  /// app does not open with everything animating at once.
  final bool animate;
}

class _TextMessage extends _Message {
  _TextMessage(
    this.body, {
    required super.mine,
    required super.at,
    super.animate = false,
  });

  final String body;
}

class _GifMessage extends _Message {
  _GifMessage(
    this.gif, {
    required super.mine,
    required super.at,
  });

  final GiphyGif gif;
}

/// The conversation. Picking a GIF appends it as footage.
class _ThreadScreen extends StatefulWidget {
  const _ThreadScreen({required this.apiKey, required this.onChangeKey});

  final String apiKey;
  final VoidCallback onChangeKey;

  @override
  State<_ThreadScreen> createState() => _ThreadScreenState();
}

class _ThreadScreenState extends State<_ThreadScreen> {
  final TextEditingController _composer = TextEditingController();
  final List<_Message> _messages = <_Message>[];

  @override
  void initState() {
    super.initState();
    final now = DateTime.now();
    _messages.addAll(<_Message>[
      _TextMessage(
        'so the deploy went out at 4:58 on a friday',
        mine: false,
        at: now.subtract(const Duration(minutes: 6)),
      ),
      _TextMessage(
        'I have no words for this. only a gif.',
        mine: false,
        at: now.subtract(const Duration(minutes: 5)),
      ),
      _TextMessage(
        'give me a second',
        mine: true,
        at: now.subtract(const Duration(minutes: 4)),
      ),
    ]);
  }

  @override
  void dispose() {
    _composer.dispose();
    super.dispose();
  }

  bool get _hasSentGif => _messages.any((m) => m is _GifMessage);

  /// Opens the picker and files whatever comes back into the thread.
  ///
  /// This is the whole integration: one call, one nullable result.
  Future<void> _pickGif() async {
    final gif = await GiphyBottomSheet.open(
      context,
      apiKey: widget.apiKey,
      showTypeSelector: true,
      rating: GiphyRating.pg,
      theme: _pickerTheme,
      labels: _pickerLabels,
    );

    if (gif == null || !mounted) return;

    setState(() {
      _messages.add(_GifMessage(gif, mine: true, at: DateTime.now()));
    });
  }

  void _sendText() {
    final body = _composer.text.trim();
    if (body.isEmpty) return;
    setState(() {
      _messages.add(
        _TextMessage(body, mine: true, at: DateTime.now(), animate: true),
      );
      _composer.clear();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: _ground,
        surfaceTintColor: Colors.transparent,
        elevation: 0,
        titleSpacing: 20,
        shape: const Border(bottom: BorderSide(color: Color(0xFFC9D0C2))),
        title: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('THREAD · REACTION DESK', style: _mono(size: 9)),
            const SizedBox(height: 3),
            const Text(
              'Priya',
              style: TextStyle(
                fontSize: 19,
                fontWeight: FontWeight.w700,
                letterSpacing: -0.3,
                color: _ink,
              ),
            ),
          ],
        ),
        actions: [
          PopupMenuButton<String>(
            iconColor: _ink,
            color: _paper,
            onSelected: (_) => widget.onChangeKey(),
            itemBuilder: (context) => const [
              PopupMenuItem<String>(
                value: 'key',
                child: Text('Change API key'),
              ),
            ],
          ),
        ],
      ),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              reverse: true,
              padding: const EdgeInsets.fromLTRB(16, 20, 16, 12),
              itemCount: _messages.length,
              itemBuilder: (context, index) {
                final message = _messages[_messages.length - 1 - index];
                return _Bubble(
                  key: ObjectKey(message),
                  message: message,
                  onOpenDetails: message is _GifMessage
                      ? () => _showGifDetails(context, message.gif)
                      : null,
                );
              },
            ),
          ),
          if (!_hasSentGif)
            Padding(
              padding: const EdgeInsets.only(bottom: 4),
              child: Text('TAP GIF TO PICK A REACTION', style: _mono(size: 9)),
            ),
          _ComposeBar(
            controller: _composer,
            onPickGif: _pickGif,
            onSend: _sendText,
          ),
        ],
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Bubbles
// ---------------------------------------------------------------------------

/// Positions a message on its side of the thread and reveals it on arrival.
class _Bubble extends StatelessWidget {
  const _Bubble({super.key, required this.message, this.onOpenDetails});

  final _Message message;
  final VoidCallback? onOpenDetails;

  @override
  Widget build(BuildContext context) {
    final maxWidth = math.min(340.0, MediaQuery.sizeOf(context).width * 0.74);

    final Widget content = switch (message) {
      _TextMessage(:final body) => _TextBody(body: body, mine: message.mine),
      _GifMessage(:final gif) => _FootageCard(
          gif: gif,
          onTap: onOpenDetails,
        ),
    };

    return Padding(
      padding: const EdgeInsets.only(bottom: 10),
      child: Column(
        crossAxisAlignment:
            message.mine ? CrossAxisAlignment.end : CrossAxisAlignment.start,
        children: [
          _ArriveIn(
            enabled: message.animate,
            child: ConstrainedBox(
              constraints: BoxConstraints(maxWidth: maxWidth),
              child: content,
            ),
          ),
          const SizedBox(height: 4),
          Text(_hhmm(message.at), style: _mono(size: 9)),
        ],
      ),
    );
  }
}

/// A plain text message.
class _TextBody extends StatelessWidget {
  const _TextBody({required this.body, required this.mine});

  final String body;
  final bool mine;

  @override
  Widget build(BuildContext context) {
    return DecoratedBox(
      decoration: BoxDecoration(
        color: mine ? _ink : _paper,
        borderRadius: _bubbleRadius(mine),
      ),
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 11),
        child: Text(
          body,
          style: TextStyle(
            fontSize: 15,
            height: 1.35,
            color: mine ? _paper : _ink,
          ),
        ),
      ),
    );
  }
}

/// The signature element: a picked GIF filed as a strip of footage, captioned
/// with its own GIPHY title.
class _FootageCard extends StatelessWidget {
  const _FootageCard({required this.gif, this.onTap});

  final GiphyGif gif;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    final rendition = gif.images.fixedHeight;
    final width = rendition.widthAsInt;
    final height = rendition.heightAsInt;
    final ratio = (width == null || height == null || height == 0)
        ? 1.6
        : (width / height).clamp(0.7, 2.2);

    return Semantics(
      label: 'Sent GIF: ${_plainTitle(gif)}. Tap for details.',
      button: true,
      child: ClipRRect(
        borderRadius: _bubbleRadius(true),
        child: Material(
          color: _ink,
          child: InkWell(
            onTap: onTap,
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                AspectRatio(
                  aspectRatio: ratio.toDouble(),
                  child: Image.network(
                    rendition.url,
                    fit: BoxFit.cover,
                    loadingBuilder: (context, child, progress) {
                      if (progress == null) return child;
                      return const ColoredBox(
                        color: Color(0xFF2A2D30),
                        child: Center(
                          child: SizedBox(
                            width: 18,
                            height: 18,
                            child: CircularProgressIndicator(
                              strokeWidth: 2,
                              color: _marigold,
                            ),
                          ),
                        ),
                      );
                    },
                    errorBuilder: (context, error, stack) => ColoredBox(
                      color: const Color(0xFF2A2D30),
                      child: Center(
                        child: Text(
                          'FRAME UNAVAILABLE',
                          style: _mono(size: 9, color: _brick),
                        ),
                      ),
                    ),
                  ),
                ),
                _Slate(gif: gif),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

/// The marigold strip under the footage: title on the left, frame on the right.
class _Slate extends StatelessWidget {
  const _Slate({required this.gif});

  final GiphyGif gif;

  @override
  Widget build(BuildContext context) {
    final rendition = gif.images.fixedHeight;
    final w = rendition.widthAsInt;
    final h = rendition.heightAsInt;

    return ColoredBox(
      color: _marigold,
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
        child: Row(
          children: [
            Text('▸', style: _mono(size: 9, color: _ink)),
            const SizedBox(width: 7),
            Expanded(
              child: Text(
                _slateTitle(gif),
                maxLines: 1,
                overflow: TextOverflow.ellipsis,
                style: _mono(size: 9, color: _ink, weight: FontWeight.w700),
              ),
            ),
            const SizedBox(width: 10),
            Text(
              (w == null || h == null) ? '—' : '$w×$h',
              style: _mono(size: 9, color: const Color(0xFF6B5417)),
            ),
          ],
        ),
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Compose bar
// ---------------------------------------------------------------------------

/// Text field, the GIF affordance, and send.
class _ComposeBar extends StatelessWidget {
  const _ComposeBar({
    required this.controller,
    required this.onPickGif,
    required this.onSend,
  });

  final TextEditingController controller;
  final Future<void> Function() onPickGif;
  final VoidCallback onSend;

  @override
  Widget build(BuildContext context) {
    return DecoratedBox(
      decoration: const BoxDecoration(
        color: _paper,
        border: Border(top: BorderSide(color: Color(0xFFC9D0C2))),
      ),
      child: SafeArea(
        top: false,
        child: Padding(
          padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.end,
            children: [
              // The one bright thing on screen, because it is the one thing
              // this example is about.
              Semantics(
                button: true,
                label: 'Pick a GIF',
                child: Material(
                  color: _marigold,
                  borderRadius: BorderRadius.circular(9),
                  child: InkWell(
                    onTap: () => onPickGif(),
                    borderRadius: BorderRadius.circular(9),
                    child: Padding(
                      padding: const EdgeInsets.symmetric(
                        horizontal: 12,
                        vertical: 11,
                      ),
                      child: Text(
                        'GIF',
                        style: _mono(
                          size: 11,
                          color: _ink,
                          weight: FontWeight.w800,
                        ),
                      ),
                    ),
                  ),
                ),
              ),
              const SizedBox(width: 10),
              Expanded(
                child: TextField(
                  controller: controller,
                  minLines: 1,
                  maxLines: 4,
                  textInputAction: TextInputAction.send,
                  onSubmitted: (_) => onSend(),
                  cursorColor: _ink,
                  style: const TextStyle(fontSize: 15, color: _ink),
                  decoration: InputDecoration(
                    border: InputBorder.none,
                    isDense: true,
                    contentPadding: const EdgeInsets.symmetric(vertical: 10),
                    hintText: 'Message',
                    hintStyle: const TextStyle(
                      fontSize: 15,
                      color: _graphite,
                    ),
                  ),
                ),
              ),
              const SizedBox(width: 6),
              ValueListenableBuilder<TextEditingValue>(
                valueListenable: controller,
                builder: (context, value, _) {
                  final ready = value.text.trim().isNotEmpty;
                  return IconButton(
                    onPressed: ready ? onSend : null,
                    icon: const Icon(Icons.arrow_upward_rounded, size: 20),
                    style: IconButton.styleFrom(
                      backgroundColor: ready ? _ink : const Color(0xFFE4E7DF),
                      foregroundColor: ready ? _paper : _graphite,
                      disabledBackgroundColor: const Color(0xFFE4E7DF),
                      disabledForegroundColor: _graphite,
                    ),
                    tooltip: 'Send',
                  );
                },
              ),
            ],
          ),
        ),
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Details
// ---------------------------------------------------------------------------

/// Shows what the package actually handed back: the decoded [GiphyGif].
Future<void> _showGifDetails(BuildContext context, GiphyGif gif) {
  return showModalBottomSheet<void>(
    context: context,
    backgroundColor: _paper,
    isScrollControlled: true,
    shape: const RoundedRectangleBorder(
      borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
    ),
    builder: (sheetContext) {
      final original = gif.images.original;

      return SafeArea(
        child: Padding(
          padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text('SELECTED GIF', style: _mono(weight: FontWeight.w700)),
              const SizedBox(height: 10),
              Text(
                _plainTitle(gif),
                style: const TextStyle(
                  fontSize: 20,
                  height: 1.15,
                  fontWeight: FontWeight.w700,
                  letterSpacing: -0.3,
                  color: _ink,
                ),
              ),
              const SizedBox(height: 18),
              _DetailRow(label: 'ID', value: gif.id),
              _DetailRow(label: 'RATING', value: gif.rating ?? '—'),
              _DetailRow(
                label: 'FRAME',
                value: (original.widthAsInt == null ||
                        original.heightAsInt == null)
                    ? '—'
                    : '${original.widthAsInt} × ${original.heightAsInt}',
              ),
              _DetailRow(label: 'SIZE', value: _kb(original.size)),
              _DetailRow(label: 'ORIGINAL', value: original.url),
              _DetailRow(
                label: 'THUMBNAIL',
                value: gif.images.fixedHeight.url,
              ),
              _DetailRow(label: 'PREVIEW', value: gif.images.preview.url),
              const SizedBox(height: 14),
              Align(
                alignment: Alignment.centerRight,
                child: TextButton(
                  onPressed: () async {
                    final messenger = ScaffoldMessenger.of(sheetContext);
                    Navigator.of(sheetContext).pop();
                    await Clipboard.setData(
                      ClipboardData(text: original.url),
                    );
                    messenger.showSnackBar(
                      SnackBar(
                        backgroundColor: _ink,
                        behavior: SnackBarBehavior.floating,
                        content: Text(
                          'Copied the original URL.',
                          style: _mono(size: 11, color: _paper),
                        ),
                      ),
                    );
                  },
                  style: TextButton.styleFrom(foregroundColor: _ink),
                  child: Text(
                    'COPY ORIGINAL URL',
                    style: _mono(size: 10, color: _ink, weight: FontWeight.w800),
                  ),
                ),
              ),
            ],
          ),
        ),
      );
    },
  );
}

/// One label/value pair in the details sheet.
class _DetailRow extends StatelessWidget {
  const _DetailRow({required this.label, required this.value});

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 10),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          SizedBox(width: 92, child: Text(label, style: _mono(size: 9))),
          Expanded(
            child: Text(
              value,
              maxLines: 2,
              overflow: TextOverflow.ellipsis,
              style: _mono(size: 11, color: _ink, weight: FontWeight.w500),
            ),
          ),
        ],
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Motion
// ---------------------------------------------------------------------------

/// Reveals its child once, by unmasking it downward.
///
/// The only animation in the app: a GIF arriving should feel like footage being
/// filed. Honours the platform's reduced-motion setting.
class _ArriveIn extends StatefulWidget {
  const _ArriveIn({required this.child, this.enabled = true});

  final Widget child;
  final bool enabled;

  @override
  State<_ArriveIn> createState() => _ArriveInState();
}

class _ArriveInState extends State<_ArriveIn>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller = AnimationController(
    duration: const Duration(milliseconds: 260),
    vsync: this,
  );

  late final Animation<double> _reveal = CurvedAnimation(
    parent: _controller,
    curve: Curves.easeOutCubic,
  );

  bool _started = false;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    if (_started) return;
    _started = true;

    final skip = !widget.enabled || MediaQuery.of(context).disableAnimations;
    if (skip) {
      _controller.value = 1;
    } else {
      _controller.forward();
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _reveal,
      builder: (context, child) {
        // easeOutCubic stays within 0..1, so no clamp is needed — and
        // num.clamp would not return a double anyway.
        return Opacity(
          opacity: _reveal.value,
          child: ClipRect(
            child: Align(
              alignment: Alignment.topCenter,
              heightFactor: math.max(0.01, _reveal.value),
              child: child,
            ),
          ),
        );
      },
      child: widget.child,
    );
  }
}

// ---------------------------------------------------------------------------
// Formatting
// ---------------------------------------------------------------------------

BorderRadius _bubbleRadius(bool mine) {
  return BorderRadius.only(
    topLeft: const Radius.circular(16),
    topRight: const Radius.circular(16),
    bottomLeft: Radius.circular(mine ? 16 : 4),
    bottomRight: Radius.circular(mine ? 4 : 16),
  );
}

String _hhmm(DateTime at) {
  final h = at.hour.toString().padLeft(2, '0');
  final m = at.minute.toString().padLeft(2, '0');
  return '$h:$m';
}

/// GIPHY titles usually end in "GIF", which is redundant once it is on screen
/// inside a GIF.
String _plainTitle(GiphyGif gif) {
  var title = gif.title.trim();
  if (title.isEmpty) return 'Untitled';
  if (title.toLowerCase().endsWith(' gif')) {
    title = title.substring(0, title.length - 4).trimRight();
  }
  return title.isEmpty ? 'Untitled' : title;
}

String _slateTitle(GiphyGif gif) => _plainTitle(gif).toUpperCase();

String _kb(String? bytes) {
  final value = int.tryParse(bytes ?? '');
  if (value == null) return '—';
  if (value < 1024) return '$value B';
  return '${(value / 1024).round()} KB';
}
0
likes
140
points
124
downloads

Documentation

API reference

Publisher

verified publisherlazyalgorithm.com

Weekly Downloads

A production-ready Flutter SDK and UI package for GIPHY integration.

Repository (GitHub)
View/report issues

Topics

#giphy #gif #stickers #picker

License

MIT (license)

Dependencies

cached_network_image, dio, equatable, flutter

More

Packages that depend on giphy_flutter