giphy_flutter 0.1.0
giphy_flutter: ^0.1.0 copied to clipboard
A production-ready Flutter SDK and UI package for GIPHY integration.
// 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';
}