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

Server-state cache for Flutter — dedupe, cache by query key, stale-while-revalidate. A TanStack Query for Flutter.

example/lib/main.dart

import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:swrly/swrly.dart';

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

class ExampleApp extends StatelessWidget {
  const ExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'swrly example',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
      home: const HomePage(),
    );
  }
}

// ── Real HTTP via dio, instrumented so the cache is *visible* ────────────────
// swrly wraps these dio calls as query functions. `calls` only increments when
// a request actually goes to the network — a cache hit does NOT touch dio, so
// the counter stays put. Each request also logs its round-trip time; cache hits
// are instant (no log line at all).

final _dio = Dio(BaseOptions(
  baseUrl: 'https://jsonplaceholder.typicode.com',
  connectTimeout: const Duration(seconds: 10),
));

class Post {
  Post({required this.id, required this.title, required this.body});
  final int id;
  final String title;
  final String body;
  factory Post.fromJson(Map<String, dynamic> j) =>
      Post(id: j['id'] as int, title: j['title'] as String, body: j['body'] as String);
}

class Net {
  static final ValueNotifier<int> calls = ValueNotifier<int>(0);
  static final ValueNotifier<List<String>> log = ValueNotifier<List<String>>([]);

  static void note(String m) {
    final next = [...log.value, m];
    log.value = next.length > 10 ? next.sublist(next.length - 10) : next;
  }

  static Future<T> _timed<T>(String label, Future<T> Function() run) async {
    calls.value += 1;
    final id = calls.value;
    note('▶ #$id  $label …');
    final sw = Stopwatch()..start();
    try {
      final r = await run();
      note('✓ #$id  $label  ${sw.elapsedMilliseconds}ms');
      return r;
    } catch (_) {
      note('✗ #$id  $label  failed');
      rethrow;
    }
  }

  static Future<List<Post>> fetchPosts() => _timed('GET /posts', () async {
        final r = await _dio.get<List<dynamic>>('/posts',
            queryParameters: {'_limit': 12});
        return r.data!
            .cast<Map<String, dynamic>>()
            .map(Post.fromJson)
            .toList();
      });

  static Future<Post> fetchPost(int id) => _timed('GET /posts/$id', () async {
        final r = await _dio.get<Map<String, dynamic>>('/posts/$id');
        return Post.fromJson(r.data!);
      });

  static Future<Post> createPost(String title) => _timed('POST /posts', () async {
        final r = await _dio.post<Map<String, dynamic>>('/posts',
            data: {'title': title, 'body': 'created via dio'});
        return Post(
            id: (r.data?['id'] as int?) ?? 101, title: title, body: 'created via dio');
      });
}

// ── UI ──────────────────────────────────────────────────────────────────────

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  int _staleSeconds = 30;

  @override
  Widget build(BuildContext context) {
    final stale = Duration(seconds: _staleSeconds);
    return Scaffold(
      appBar: AppBar(
        title: const Text('swrly + dio'),
        actions: [
          IconButton(
            icon: const Icon(Icons.delete_sweep),
            tooltip: 'Clear cache',
            onPressed: () {
              QueryClient.instance.clear();
              Net.note('🗑 cache cleared');
            },
          ),
        ],
      ),
      body: Column(
        children: [
          const _NetworkPanel(),
          const Divider(height: 1),
          _Controls(
            staleSeconds: _staleSeconds,
            onStale: (v) => setState(() => _staleSeconds = v),
            onInvalidate: () {
              Net.note('♻ invalidateQueries([posts])');
              QueryClient.instance.invalidateQueries(const ['posts']);
            },
          ),
          const Divider(height: 1),
          Expanded(child: _PostsList(staleTime: stale)),
          const Divider(height: 1),
          _CreatePostForm(),
        ],
      ),
    );
  }
}

/// The metric + log that make the cache observable.
class _NetworkPanel extends StatelessWidget {
  const _NetworkPanel();

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return Container(
      color: scheme.surfaceContainerHighest,
      padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              Icon(Icons.cloud_outlined, size: 18, color: scheme.primary),
              const SizedBox(width: 6),
              const Text('dio requests: ',
                  style: TextStyle(fontWeight: FontWeight.w600)),
              ValueListenableBuilder<int>(
                valueListenable: Net.calls,
                builder: (_, n, __) => Text('$n',
                    style: TextStyle(
                        fontWeight: FontWeight.bold,
                        fontSize: 16,
                        color: scheme.primary)),
              ),
              const Spacer(),
              const Text('cache hit = no request',
                  style: TextStyle(fontSize: 11, color: Colors.grey)),
            ],
          ),
          const SizedBox(height: 4),
          SizedBox(
            height: 88,
            child: ValueListenableBuilder<List<String>>(
              valueListenable: Net.log,
              builder: (_, lines, __) => ListView(
                reverse: true,
                children: [
                  for (final l in lines.reversed)
                    Text(l,
                        style: const TextStyle(
                            fontFamily: 'monospace', fontSize: 11.5)),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class _Controls extends StatelessWidget {
  const _Controls({
    required this.staleSeconds,
    required this.onStale,
    required this.onInvalidate,
  });

  final int staleSeconds;
  final ValueChanged<int> onStale;
  final VoidCallback onInvalidate;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
      child: Row(
        children: [
          const Text('staleTime:'),
          const SizedBox(width: 8),
          SegmentedButton<int>(
            style: const ButtonStyle(
                visualDensity: VisualDensity.compact,
                tapTargetSize: MaterialTapTargetSize.shrinkWrap),
            segments: const [
              ButtonSegment(value: 0, label: Text('0s')),
              ButtonSegment(value: 30, label: Text('30s')),
            ],
            selected: {staleSeconds},
            onSelectionChanged: (s) => onStale(s.first),
          ),
          const Spacer(),
          OutlinedButton.icon(
            icon: const Icon(Icons.autorenew, size: 18),
            label: const Text('Invalidate'),
            onPressed: onInvalidate,
          ),
        ],
      ),
    );
  }
}

class _PostsList extends StatelessWidget {
  const _PostsList({required this.staleTime});
  final Duration staleTime;

  @override
  Widget build(BuildContext context) {
    return QueryBuilder<List<Post>>(
      queryKey: const ['posts'],
      queryFn: Net.fetchPosts,
      staleTime: staleTime,
      builder: (context, state, refetch) {
        if (state.isLoading && !state.hasData) {
          return const Center(child: CircularProgressIndicator());
        }
        if (state.isError && !state.hasData) {
          return _ErrorView(error: state.error, onRetry: refetch);
        }
        final posts = state.data ?? const <Post>[];
        return Stack(
          children: [
            ListView.separated(
              itemCount: posts.length,
              separatorBuilder: (_, __) => const Divider(height: 1),
              itemBuilder: (context, i) {
                final p = posts[i];
                return ListTile(
                  dense: true,
                  leading: CircleAvatar(child: Text('${p.id}')),
                  title: Text(p.title, maxLines: 1, overflow: TextOverflow.ellipsis),
                  subtitle:
                      Text(p.body, maxLines: 1, overflow: TextOverflow.ellipsis),
                  trailing: const Icon(Icons.chevron_right),
                  // Detail is a SEPARATE query keyed by id → cached per post.
                  // Re-opening the same post within staleTime is instant (no
                  // dio call); a different post fetches once and caches.
                  onTap: () => showModalBottomSheet<void>(
                    context: context,
                    showDragHandle: true,
                    builder: (_) => _PostDetail(id: p.id, staleTime: staleTime),
                  ),
                );
              },
            ),
            if (state.isFetching)
              const Positioned(
                top: 8,
                right: 8,
                child: Row(children: [
                  SizedBox(
                      width: 14,
                      height: 14,
                      child: CircularProgressIndicator(strokeWidth: 2)),
                  SizedBox(width: 6),
                  Text('refetching…', style: TextStyle(fontSize: 11)),
                ]),
              ),
          ],
        );
      },
    );
  }
}

class _PostDetail extends StatelessWidget {
  const _PostDetail({required this.id, required this.staleTime});
  final int id;
  final Duration staleTime;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 280,
      child: Padding(
        padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
        child: QueryBuilder<Post>(
          queryKey: ['post', id], // keyed by id
          queryFn: () => Net.fetchPost(id),
          staleTime: staleTime,
          builder: (context, state, refetch) {
            if (state.isLoading && !state.hasData) {
              return const Center(
                child: Column(mainAxisSize: MainAxisSize.min, children: [
                  CircularProgressIndicator(),
                  SizedBox(height: 12),
                  Text('fetching from network…'),
                ]),
              );
            }
            if (state.isError && !state.hasData) {
              return _ErrorView(error: state.error, onRetry: refetch);
            }
            final p = state.data!;
            return Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Row(children: [
                  CircleAvatar(child: Text('${p.id}')),
                  const SizedBox(width: 10),
                  Expanded(
                    child: Text('Post #${p.id}',
                        style: Theme.of(context).textTheme.titleMedium),
                  ),
                ]),
                const SizedBox(height: 12),
                Text(p.title,
                    style: const TextStyle(fontWeight: FontWeight.bold)),
                const SizedBox(height: 8),
                Text(p.body),
                const Spacer(),
                Text('Re-open this post within staleTime → served from cache '
                    '(no dio request).',
                    style: Theme.of(context).textTheme.bodySmall),
              ],
            );
          },
        ),
      ),
    );
  }
}

class _ErrorView extends StatelessWidget {
  const _ErrorView({required this.error, required this.onRetry});
  final Object? error;
  final Future<void> Function() onRetry;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(mainAxisSize: MainAxisSize.min, children: [
        const Icon(Icons.error_outline, color: Colors.red, size: 44),
        const SizedBox(height: 8),
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 24),
          child: Text('$error', textAlign: TextAlign.center),
        ),
        const SizedBox(height: 12),
        FilledButton.tonal(onPressed: onRetry, child: const Text('Retry')),
      ]),
    );
  }
}

class _CreatePostForm extends StatefulWidget {
  @override
  State<_CreatePostForm> createState() => _CreatePostFormState();
}

class _CreatePostFormState extends State<_CreatePostForm> {
  final _controller = TextEditingController(text: 'A new post');

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(12),
      child: MutationBuilder<Post, String>(
        mutationFn: Net.createPost,
        onSuccess: (post, _) {
          // Optimistic write: prepend to the cached list with setQueryData —
          // it shows instantly, no refetch (dio counter doesn't move).
          final current =
              QueryClient.instance.getQueryData<List<Post>>(const ['posts']) ??
                  const <Post>[];
          QueryClient.instance
              .setQueryData<List<Post>>(const ['posts'], [post, ...current]);
          Net.note('+ setQueryData → #${post.id} prepended (optimistic)');
        },
        builder: (context, mutate, state) => Row(
          children: [
            Expanded(
              child: TextField(
                controller: _controller,
                decoration: const InputDecoration(
                  border: OutlineInputBorder(),
                  isDense: true,
                  labelText: 'New post title',
                ),
              ),
            ),
            const SizedBox(width: 8),
            FilledButton(
              onPressed: state.isLoading ? null : () => mutate(_controller.text),
              child: state.isLoading
                  ? const SizedBox(
                      width: 16,
                      height: 16,
                      child: CircularProgressIndicator(
                          strokeWidth: 2, color: Colors.white))
                  : const Text('Create'),
            ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}
2
likes
0
points
443
downloads

Publisher

unverified uploader

Weekly Downloads

Server-state cache for Flutter — dedupe, cache by query key, stale-while-revalidate. A TanStack Query for Flutter.

Repository (GitHub)
View/report issues

Topics

#state-management #cache #http #async #networking

License

unknown (license)

Dependencies

flutter

More

Packages that depend on swrly