sling_gql 0.1.1
sling_gql: ^0.1.1 copied to clipboard
GraphQL client for Flutter where the widget is the query — read a field, get the query. Proof of concept.
// A complete, self-contained sling_gql app: one query, one mutation, one
// optimistic update. Point [endpoint] at a server exposing the schema below
// and run it.
//
// In a real app the `Query` / `User` / `Mutation` classes at the bottom of
// this file are generated by `sling_gql_gen` from your schema's introspection
// (`dart run sling_gql_gen --schema schema.json --out lib/generated/schema.dart`)
// and imported from there. They are written out here so the example runs on
// its own. A larger app (pagination, favourites, network log) lives in the
// repository's `example/` directory.
//
// type Query { me: User! user(id: ID!): User }
// type User { id: ID! name: String! age: Int friends(limit: Int): [User!]! }
// type Mutation { rename(id: ID!, name: String!): User! }
import 'package:flutter/material.dart';
import 'package:sling_gql/sling_gql.dart';
final endpoint = Uri.parse('https://example.com/graphql');
void main() {
final client = SlingClient<Query>(
endpoint: endpoint,
rootFactory: Query.root,
);
runApp(
SlingScope<Query>(
client: client,
schema: slingSchema,
child: const MaterialApp(home: ProfilePage()),
),
);
}
/// Reads `me`, its name, age and first three friends. Nothing is declared up
/// front: the fields read during `build` become the query, fetched once at
/// the end of the frame, and the widget rebuilds when the data lands.
class ProfilePage extends StatelessWidget {
const ProfilePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('sling_gql')),
body: QueryBuilder<Query>(
builder: (context, query, state) {
// Read everything you need at the top of build, unconditionally —
// a field read only inside an `if` costs a second round trip.
final me = query.me;
final name = me.name;
final age = me.age;
final friends = me.friends(limit: 3);
if (state.error != null) {
return Center(
child: TextButton(
onPressed: state.refetch,
child: Text('Failed: ${state.error}\nTap to retry'),
),
);
}
// Before the response arrives, scalars are null and lists have one
// skeleton element, so the layout below renders as a placeholder.
return ListView(
children: [
ListTile(
title: Text(name ?? '…'),
subtitle: Text(age == null ? '' : '$age years old'),
trailing: state.isLoading
? const CircularProgressIndicator.adaptive()
: RenameButton(user: me),
),
const Divider(),
for (final friend in friends)
ListTile(
leading: const Icon(Icons.person),
title: Text(friend.name ?? '…'),
),
],
);
},
),
);
}
}
/// Runs the `rename` mutation. The response is normalized into the same
/// cache entity (`User:<id>`), so every widget showing this user rebuilds by
/// itself; the optimistic write shows the new name immediately and is rolled
/// back if the request fails.
class RenameButton extends StatelessWidget {
const RenameButton({super.key, required this.user});
final User user;
@override
Widget build(BuildContext context) {
final id = user.id;
final currentName = user.name;
return MutationBuilder<Mutation>(
builder: (context, mutate, state) => IconButton(
icon: const Icon(Icons.edit),
onPressed: state.isLoading || id == null
? null
: () async {
final newName = await askForName(context, currentName ?? '');
if (newName == null) return;
await mutate(
(m) => m.rename(id: id, name: newName)?.name,
optimistic: () => user.name = newName,
);
},
),
);
}
}
Future<String?> askForName(BuildContext context, String initial) {
final controller = TextEditingController(text: initial);
return showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Rename'),
content: TextField(controller: controller, autofocus: true),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, controller.text),
child: const Text('Save'),
),
],
),
);
}
// ---------------------------------------------------------------------------
// What `sling_gql_gen` generates for the schema above. Do not write this by
// hand in your app.
// ---------------------------------------------------------------------------
class Query extends Accessor {
Query(super.recorder, super.selection, super.path);
Query.root(Recorder r) : super(r, r.root, const []);
User get me => object('me', User.new, keyed: true)!;
User? user({required String id}) =>
object('user', User.new, args: {'id': Arg('ID!', id)}, lookup: 'User');
}
class User extends Accessor {
User(super.recorder, super.selection, super.path);
String? get id => scalar<String>('id');
String? get name => scalar<String>('name');
set name(String? v) => write('name', v);
int? get age => scalar<int>('age');
set age(int? v) => write('age', v);
List<User> friends({int? limit}) => list(
'friends',
User.new,
args: {'limit': Arg('Int', limit)},
keyed: true,
)!;
}
class Mutation extends Accessor {
Mutation(super.recorder, super.selection, super.path);
Mutation.root(Recorder r) : super(r, r.root, const []);
User? rename({required String id, required String name}) => object(
'rename',
User.new,
args: {'id': Arg('ID!', id), 'name': Arg('String!', name)},
keyed: true,
);
}
extension SlingCacheAccess on CacheScope<Query> {
User? user(String id) => entity('User', id, User.new);
}
const slingSchema = SlingSchema<Query, Mutation>(
query: Query.root,
mutation: Mutation.root,
);