qora_flutter 0.8.0 copy "qora_flutter: ^0.8.0" to clipboard
qora_flutter: ^0.8.0 copied to clipboard

A Flutter implementation for Qora, bringing powerful async state management, automatic caching, and offline-first capabilities to the widget tree.

example/example.dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:qora_flutter/qora_flutter.dart';

// ---------------------------------------------------------------------------
// App entry point
// ---------------------------------------------------------------------------

void main() {
  final client = QoraClient(
    config: QoraClientConfig(
      defaultOptions: const QoraOptions(
        staleTime: Duration(minutes: 5),
        cacheTime: Duration(minutes: 10),
        retryCount: 3,
        networkMode: NetworkMode.online,
      ),
      debugMode: kDebugMode,
      reconnectStrategy: const ReconnectStrategy(
        maxConcurrent: 5,
        jitter: Duration(milliseconds: 100),
      ),
      errorMapper: (error, stackTrace) => QoraException(
        error.toString().contains('401') ? 'Unauthorized' : 'Network error',
        originalError: error,
        stackTrace: stackTrace,
      ),
    ),
  );

  runApp(
    QoraScope(
      client: client,
      // Invalidates all queries when the app resumes after 30 s in background.
      lifecycleManager: FlutterLifecycleManager(
        qoraClient: client,
        refetchInterval: const Duration(seconds: 30),
      ),
      // Pure signal provider — no QoraClient reference required.
      connectivityManager: FlutterConnectivityManager(),
      child: const MyApp(),
    ),
  );
}

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

  @override
  Widget build(BuildContext context) {
    // NetworkStatusIndicator overlays an offline banner at the bottom
    // whenever FlutterConnectivityManager reports NetworkStatus.offline.
    return NetworkStatusIndicator(
      child: MaterialApp(
        title: 'Qora Flutter Demo',
        home: const UserListScreen(),
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Example 1 — Basic list with all four states + FetchStatus offline indicator
// ---------------------------------------------------------------------------

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Users'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            // Invalidate all "users" queries — QoraBuilder detects the
            // resulting Loading(previousData: ...) and re-fetches automatically.
            onPressed: () => context.qora.invalidateWhere(
              (key) => key.firstOrNull == 'users',
            ),
          ),
        ],
      ),
      body: QoraBuilder<List<User>>(
        queryKey: const ['users'],
        fetcher: ApiService.getUsers,
        // builder now receives fetchStatus as the third argument.
        builder: (context, state, fetchStatus) {
          return switch (state) {
            // Paused before any data — device is offline.
            Initial() when fetchStatus == FetchStatus.paused =>
              const Center(child: Text('Offline — connect to load users')),
            Initial() => const Center(child: Text('Press refresh to load')),
            Loading(:final previousData) => previousData != null
                ? Stack(children: [
                    UserListView(users: previousData),
                    const LinearProgressIndicator(),
                  ])
                : const Center(child: CircularProgressIndicator()),
            Success(:final data) => Stack(
                children: [
                  data.isEmpty
                      ? const Center(child: Text('No users found'))
                      : UserListView(users: data),
                  // Background revalidation paused (offline) — show chip.
                  if (fetchStatus == FetchStatus.paused)
                    const Positioned(
                      top: 8,
                      right: 8,
                      child: _OfflineSyncChip(),
                    ),
                ],
              ),
            Failure(:final error, :final previousData) => Column(
                children: [
                  if (previousData != null)
                    Expanded(child: UserListView(users: previousData)),
                  ErrorBanner(message: '$error'),
                ],
              ),
          };
        },
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Example 2 — Flicker-free pagination with keepPreviousData
// ---------------------------------------------------------------------------

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

  @override
  State<PaginatedUsersScreen> createState() => _PaginatedUsersScreenState();
}

class _PaginatedUsersScreenState extends State<PaginatedUsersScreen> {
  int _page = 1;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Users — paginated')),
      body: Column(
        children: [
          Expanded(
            child: QoraBuilder<List<User>>(
              queryKey: ['users', 'page', _page],
              fetcher: () => ApiService.getUsersPaged(_page),
              // Keep the previous page visible while the next page loads.
              keepPreviousData: true,
              builder: (context, state, _) {
                // dataOrNull returns Success.data OR Loading/Failure.previousData.
                final users = state.dataOrNull ?? [];
                return Stack(
                  children: [
                    UserListView(users: users),
                    if (state.isLoading)
                      const Positioned(
                        top: 0,
                        left: 0,
                        right: 0,
                        child: LinearProgressIndicator(),
                      ),
                  ],
                );
              },
            ),
          ),
          _PaginationControls(
            page: _page,
            onPrev: _page > 1 ? () => setState(() => _page--) : null,
            onNext: () => setState(() => _page++),
          ),
        ],
      ),
    );
  }
}

class _PaginationControls extends StatelessWidget {
  final int page;
  final VoidCallback? onPrev;
  final VoidCallback onNext;

  const _PaginationControls({
    required this.page,
    required this.onPrev,
    required this.onNext,
  });

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        IconButton(icon: const Icon(Icons.chevron_left), onPressed: onPrev),
        Text('Page $page'),
        IconButton(icon: const Icon(Icons.chevron_right), onPressed: onNext),
      ],
    );
  }
}

// ---------------------------------------------------------------------------
// Example 3 — Detail screen with manual refresh + fetchStatus
// ---------------------------------------------------------------------------

class UserDetailScreen extends StatelessWidget {
  final int userId;

  const UserDetailScreen({super.key, required this.userId});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('User detail'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: () => context.qora.invalidate(['users', userId]),
          ),
        ],
      ),
      body: QoraBuilder<User>(
        queryKey: ['users', userId],
        fetcher: () => ApiService.getUser(userId),
        builder: (context, state, fetchStatus) {
          return switch (state) {
            Initial() || Loading(previousData: null) => const Center(
                child: CircularProgressIndicator(),
              ),
            Loading(:final previousData?) => UserDetailView(
                user: previousData,
                isRefreshing: true,
              ),
            Success(:final data, :final updatedAt) => UserDetailView(
                user: data,
                updatedAt: updatedAt,
                // Show a subtle indicator during background revalidation.
                isRefreshing: fetchStatus == FetchStatus.fetching,
              ),
            Failure(:final error, previousData: null) => _ErrorScreen(
                message: '$error',
                onRetry: () => context.qora.invalidate(['users', userId]),
              ),
            Failure(:final error, :final previousData?) => Column(
                children: [
                  Expanded(child: UserDetailView(user: previousData)),
                  ErrorBanner(message: '$error'),
                ],
              ),
          };
        },
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Example 4 — QoraStateBuilder: observe without fetching
// ---------------------------------------------------------------------------

/// Shows the user avatar wherever it is needed in the tree, without
/// triggering a second fetch — the data is owned by [UserDetailScreen].
class UserAvatarWidget extends StatelessWidget {
  final int userId;

  const UserAvatarWidget({super.key, required this.userId});

  @override
  Widget build(BuildContext context) {
    return QoraStateBuilder<User>(
      queryKey: ['users', userId],
      builder: (context, state) {
        return switch (state) {
          Success(:final data) => CircleAvatar(
              backgroundImage: NetworkImage(data.avatarUrl),
            ),
          Loading() => const CircleAvatar(child: CircularProgressIndicator()),
          _ => const CircleAvatar(child: Icon(Icons.person)),
        };
      },
    );
  }
}

// ---------------------------------------------------------------------------
// Example 5 — Optimistic update
// ---------------------------------------------------------------------------

class UpdateUserButton extends StatelessWidget {
  final int userId;
  final String newName;

  const UpdateUserButton({
    super.key,
    required this.userId,
    required this.newName,
  });

  Future<void> _update(BuildContext context) async {
    final client = context.qora;
    final key = ['users', userId];

    // 1. Snapshot current data for potential rollback.
    final snapshot = client.getQueryData<User>(key);

    // 2. Optimistic update — UI reflects the change immediately.
    if (snapshot != null) {
      client.setQueryData<User>(key, snapshot.copyWith(name: newName));
    }

    try {
      // 3. Confirm with the real server response.
      final updated = await ApiService.updateUser(userId, newName);
      client.setQueryData<User>(key, updated);

      // 4. Invalidate the user list so it reflects the new name.
      client.invalidateWhere((k) => k.firstOrNull == 'users' && k.length == 1);
    } catch (error) {
      // 5. Roll back on failure.
      client.restoreQueryData<User>(key, snapshot);

      if (context.mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Update failed: $error')),
        );
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () => _update(context),
      child: const Text('Save'),
    );
  }
}

// ---------------------------------------------------------------------------
// Example 6 — Conditional (dependent) query
// ---------------------------------------------------------------------------

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

  @override
  State<ConditionalQueryWidget> createState() => _ConditionalQueryWidgetState();
}

class _ConditionalQueryWidgetState extends State<ConditionalQueryWidget> {
  bool _enabled = false;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        SwitchListTile(
          title: const Text('Enable query'),
          value: _enabled,
          onChanged: (v) => setState(() => _enabled = v),
        ),
        QoraBuilder<String>(
          queryKey: const ['conditional'],
          fetcher: ApiService.getData,
          enabled: _enabled,
          builder: (context, state, _) => ListTile(
            title: Text('State: ${state.runtimeType}'),
            subtitle: state is Success<String> ? Text(state.data) : null,
          ),
        ),
      ],
    );
  }
}

// ---------------------------------------------------------------------------
// Example 7 — Offline mutation queue with isOptimistic
// ---------------------------------------------------------------------------

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Create user')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: QoraMutationBuilder<User, String, void>(
          mutator: ApiService.createUser,
          options: MutationOptions(
            // Enqueue in FIFO offline queue — replays on reconnect.
            offlineQueue: true,
            // Provide a synthetic User so the UI updates immediately offline.
            optimisticResponse: (name) => User(
              id: -1,
              name: name,
              email: '(pending sync)',
              avatarUrl: 'https://i.pravatar.cc/150?img=0',
            ),
            onSuccess: (_, __, ___) async {
              // Invalidate user list when the server confirms the creation.
              context.qora.invalidateWhere(
                (k) => k.firstOrNull == 'users',
              );
            },
          ),
          builder: (context, state, mutate) {
            // true only when queued offline with an optimisticResponse.
            final isOptimistic = state is MutationSuccess && state.isOptimistic;

            return Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                if (isOptimistic) const _PendingSyncBanner(),
                if (state case MutationSuccess(:final data, :final isOptimistic)
                    when !isOptimistic)
                  _SuccessBanner(name: data.name),
                if (state case MutationFailure(:final error))
                  ErrorBanner(message: '$error'),
                const SizedBox(height: 16),
                FilledButton(
                  onPressed: state.isPending ? null : () => mutate('New User'),
                  child: state.isPending
                      ? const SizedBox.square(
                          dimension: 20,
                          child: CircularProgressIndicator(strokeWidth: 2),
                        )
                      : const Text('Create user'),
                ),
              ],
            );
          },
        ),
      ),
    );
  }
}

class _PendingSyncBanner extends StatelessWidget {
  const _PendingSyncBanner();

  @override
  Widget build(BuildContext context) {
    return ColoredBox(
      color: Colors.orange.shade50,
      child: const Padding(
        padding: EdgeInsets.all(8),
        child: Row(
          children: [
            Icon(Icons.schedule, size: 16, color: Colors.orange),
            SizedBox(width: 8),
            Text('Queued — will sync when back online'),
          ],
        ),
      ),
    );
  }
}

class _SuccessBanner extends StatelessWidget {
  final String name;

  const _SuccessBanner({required this.name});

  @override
  Widget build(BuildContext context) {
    return ColoredBox(
      color: Colors.green.shade50,
      child: Padding(
        padding: const EdgeInsets.all(8),
        child: Row(
          children: [
            const Icon(Icons.check_circle, size: 16, color: Colors.green),
            const SizedBox(width: 8),
            Text('$name created successfully'),
          ],
        ),
      ),
    );
  }
}

class _OfflineSyncChip extends StatelessWidget {
  const _OfflineSyncChip();

  @override
  Widget build(BuildContext context) {
    return const ColoredBox(
      color: Colors.black54,
      child: Padding(
        padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(Icons.sync, size: 12, color: Colors.white),
            SizedBox(width: 4),
            Text('Sync pending',
                style: TextStyle(color: Colors.white, fontSize: 12)),
          ],
        ),
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Fake models and API
// ---------------------------------------------------------------------------

class User {
  final int id;
  final String name;
  final String email;
  final String avatarUrl;

  const User({
    required this.id,
    required this.name,
    required this.email,
    required this.avatarUrl,
  });

  User copyWith({String? name, String? email}) => User(
        id: id,
        name: name ?? this.name,
        email: email ?? this.email,
        avatarUrl: avatarUrl,
      );
}

class ApiService {
  static Future<List<User>> getUsers() async {
    await Future<void>.delayed(const Duration(seconds: 1));
    return List.generate(
      10,
      (i) => User(
        id: i,
        name: 'User $i',
        email: 'user$i@example.com',
        avatarUrl: 'https://i.pravatar.cc/150?img=$i',
      ),
    );
  }

  static Future<List<User>> getUsersPaged(int page) async {
    await Future<void>.delayed(const Duration(milliseconds: 500));
    final offset = (page - 1) * 10;
    return List.generate(
      10,
      (i) => User(
        id: offset + i,
        name: 'User ${offset + i}',
        email: 'user${offset + i}@example.com',
        avatarUrl: 'https://i.pravatar.cc/150?img=${offset + i}',
      ),
    );
  }

  static Future<User> getUser(int id) async {
    await Future<void>.delayed(const Duration(milliseconds: 500));
    return User(
      id: id,
      name: 'User $id',
      email: 'user$id@example.com',
      avatarUrl: 'https://i.pravatar.cc/150?img=$id',
    );
  }

  static Future<User> updateUser(int id, String newName) async {
    await Future<void>.delayed(const Duration(milliseconds: 300));
    return User(
      id: id,
      name: newName,
      email: 'user$id@example.com',
      avatarUrl: 'https://i.pravatar.cc/150?img=$id',
    );
  }

  static Future<User> createUser(String name) async {
    await Future<void>.delayed(const Duration(milliseconds: 500));
    return User(
      id: DateTime.now().millisecondsSinceEpoch,
      name: name,
      email: '${name.toLowerCase().replaceAll(' ', '')}@example.com',
      avatarUrl: 'https://i.pravatar.cc/150?img=99',
    );
  }

  static Future<String> getData() async {
    await Future<void>.delayed(const Duration(seconds: 1));
    return 'Some data';
  }
}

// ---------------------------------------------------------------------------
// Shared UI widgets
// ---------------------------------------------------------------------------

class UserListView extends StatelessWidget {
  final List<User> users;

  const UserListView({super.key, required this.users});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: users.length,
      itemBuilder: (context, i) {
        final user = users[i];
        return ListTile(
          leading: CircleAvatar(
            backgroundImage: NetworkImage(user.avatarUrl),
          ),
          title: Text(user.name),
          subtitle: Text(user.email),
          onTap: () => Navigator.push(
            context,
            MaterialPageRoute<void>(
              builder: (_) => UserDetailScreen(userId: user.id),
            ),
          ),
        );
      },
    );
  }
}

class UserDetailView extends StatelessWidget {
  final User user;
  final bool isRefreshing;
  final DateTime? updatedAt;

  const UserDetailView({
    super.key,
    required this.user,
    this.isRefreshing = false,
    this.updatedAt,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          if (isRefreshing) const LinearProgressIndicator(),
          Center(
            child: CircleAvatar(
              radius: 50,
              backgroundImage: NetworkImage(user.avatarUrl),
            ),
          ),
          const SizedBox(height: 16),
          Text(user.name, style: Theme.of(context).textTheme.headlineMedium),
          Text(user.email),
          if (updatedAt != null)
            Text(
              'Updated: ${updatedAt!.toLocal()}',
              style: const TextStyle(fontSize: 12, color: Colors.grey),
            ),
        ],
      ),
    );
  }
}

class ErrorBanner extends StatelessWidget {
  final String message;

  const ErrorBanner({super.key, required this.message});

  @override
  Widget build(BuildContext context) {
    return ColoredBox(
      color: Colors.red.shade100,
      child: Padding(
        padding: const EdgeInsets.all(8),
        child: Row(
          children: [
            const Icon(Icons.error_outline, color: Colors.red),
            const SizedBox(width: 8),
            Expanded(child: Text(message)),
          ],
        ),
      ),
    );
  }
}

class _ErrorScreen extends StatelessWidget {
  final String message;
  final VoidCallback onRetry;

  const _ErrorScreen({required this.message, required this.onRetry});

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          const Icon(Icons.cloud_off, size: 64, color: Colors.grey),
          const SizedBox(height: 16),
          Text(message),
          const SizedBox(height: 16),
          FilledButton(onPressed: onRetry, child: const Text('Retry')),
        ],
      ),
    );
  }
}
0
likes
160
points
198
downloads

Documentation

Documentation
API reference

Publisher

verified publishermeragix.dev

Weekly Downloads

A Flutter implementation for Qora, bringing powerful async state management, automatic caching, and offline-first capabilities to the widget tree.

Homepage
Repository (GitHub)
View/report issues
Contributing

License

MIT (license)

Dependencies

connectivity_plus, flutter, qora

More

Packages that depend on qora_flutter