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

A Flutter meta-framework inspired by Next.js routing, React hooks, and GetX DI. Features folder-based routing, granular reactive state, context-less navigation, useQuery/useMutation, Super Schema vali [...]

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:fuex/fuex.dart';

// ─────────────────────────────────────────────
// 1. DATA MODEL
// ─────────────────────────────────────────────

class Article {
  final int id;
  final String title;
  final String summary;
  final String imageUrl;
  final String newsSite;
  final String url;
  final DateTime publishedAt;

  Article({
    required this.id,
    required this.title,
    required this.summary,
    required this.imageUrl,
    required this.newsSite,
    required this.url,
    required this.publishedAt,
  });

  factory Article.fromJson(Map<String, dynamic> json) {
    return Article(
      id: json['id'] ?? 0,
      title: json['title'] ?? '',
      summary: json['summary'] ?? '',
      imageUrl: json['image_url'] ?? '',
      newsSite: json['news_site'] ?? '',
      url: json['url'] ?? '',
      publishedAt: DateTime.tryParse(json['published_at'] ?? '') ?? DateTime.now(),
    );
  }
}

// ─────────────────────────────────────────────
// 2. REPOSITORY (uses Fuex.find<FuexNetwork>)
// ─────────────────────────────────────────────

class NewsRepository {
  final _api = Fuex.find<FuexNetwork>();

  Future<List<Article>> fetchArticles({int limit = 20, int offset = 0}) async {
    final res = await _api.get('/v4/articles', queryParameters: {
      'limit': limit,
      'offset': offset,
    });
    final results = res.data['results'] as List;
    return results.map((e) => Article.fromJson(e)).toList();
  }

  Future<List<Article>> searchArticles(String query) async {
    final res = await _api.get('/v4/articles', queryParameters: {
      'search': query,
      'limit': 10,
    });
    final results = res.data['results'] as List;
    return results.map((e) => Article.fromJson(e)).toList();
  }
}

// ─────────────────────────────────────────────
// 3. CONTROLLER (uses Rx + FuexDisposable)
// ─────────────────────────────────────────────

class HomeController implements FuexDisposable {
  final searchQuery = ''.obs;
  final bookmarks = <Article>[].obs;

  void toggleBookmark(Article article) {
    final exists = bookmarks.any((a) => a.id == article.id);
    if (exists) {
      bookmarks.removeWhere((a) => a.id == article.id);
    } else {
      bookmarks.add(article);
    }
  }

  bool isBookmarked(int id) => bookmarks.any((a) => a.id == id);

  @override
  void onDispose() {
    // Cleanup
  }
}

// ─────────────────────────────────────────────
// 4. BINDING (auto dependency injection)
// ─────────────────────────────────────────────

class HomeBinding implements FuexBinding {
  @override
  void dependencies() {
    Fuex.put(HomeController());
  }
}

// ─────────────────────────────────────────────
// 5. GLOBAL SERVICES INIT
// ─────────────────────────────────────────────

class GlobalBindings implements FuexBinding {
  @override
  void dependencies() {
    // Built-in Network — permanent global service
    Fuex.put(
      FuexNetwork(baseUrl: 'https://api.spaceflightnewsapi.net'),
      permanent: true,
    );
  }
}

// ─────────────────────────────────────────────
// 6. PAGES
// ─────────────────────────────────────────────

// ────── HOME PAGE ──────
class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    // DI: find controller registered by HomeBinding
    final ctrl = Fuex.find<HomeController>();
    final repo = NewsRepository();

    return Scaffold(
      backgroundColor: const Color(0xFF0F0F23),
      body: NestedScrollView(
        headerSliverBuilder: (context, _) => [
          SliverAppBar(
            expandedHeight: 140.h > 200 ? 200 : 140.h,
            pinned: true,
            backgroundColor: const Color(0xFF1A1A3E),
            flexibleSpace: FlexibleSpaceBar(
              title: Text(
                '🚀 Space News',
                style: TextStyle(fontSize: 18.sp > 24 ? 24 : 18.sp),
              ),
              background: Container(
                decoration: const BoxDecoration(
                  gradient: LinearGradient(
                    colors: [Color(0xFF6C63FF), Color(0xFF1A1A3E)],
                    begin: Alignment.topLeft,
                    end: Alignment.bottomRight,
                  ),
                ),
              ),
            ),
            actions: [
              // Bookmark count via Obx (auto-tracking!)
              Obx(() => Stack(
                    children: [
                      IconButton(
                        icon: const Icon(Icons.bookmark, color: Colors.white),
                        onPressed: () => Fuex.push('/bookmarks'),
                      ),
                      if (ctrl.bookmarks.length > 0)
                        Positioned(
                          right: 6,
                          top: 6,
                          child: Container(
                            padding: const EdgeInsets.all(4),
                            decoration: const BoxDecoration(
                              color: Color(0xFFFF6B6B),
                              shape: BoxShape.circle,
                            ),
                            child: Text(
                              '${ctrl.bookmarks.length}',
                              style: const TextStyle(
                                  color: Colors.white, fontSize: 10),
                            ),
                          ),
                        ),
                    ],
                  )),
            ],
          ),
        ],
        body: Column(
          children: [
            // Search bar
            Padding(
              padding: EdgeInsets.all(12.w > 24 ? 24 : 12.w),
              child: TextField(
                style: const TextStyle(color: Colors.white),
                decoration: InputDecoration(
                  hintText: 'Search space news...',
                  hintStyle: const TextStyle(color: Color(0xFF8892B0)),
                  prefixIcon:
                      const Icon(Icons.search, color: Color(0xFF6C63FF)),
                  filled: true,
                  fillColor: const Color(0xFF1A1A3E),
                  border: OutlineInputBorder(
                    borderRadius: BorderRadius.circular(16),
                    borderSide: BorderSide.none,
                  ),
                ),
                onChanged: (v) => ctrl.searchQuery.value = v,
              ),
            ),

            // Article list via useQuery
            Expanded(
              child: useQuery<List<Article>>(
                queryKey: 'space-news',
                fetcher: () => repo.fetchArticles(),
                builder: (context, state, refetch) {
                  if (state.isLoading) {
                    return const Center(
                      child: CircularProgressIndicator(
                          color: Color(0xFF6C63FF)),
                    );
                  }
                  if (state.hasError) {
                    return Center(
                      child: Column(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          Text('Error: ${state.error}',
                              style: const TextStyle(
                                  color: Color(0xFFFF6B6B))),
                          const SizedBox(height: 12),
                          ElevatedButton(
                            onPressed: refetch,
                            child: const Text('Retry'),
                          ),
                        ],
                      ),
                    );
                  }

                  final articles = state.data!;

                  // Adaptive layout: grid on tablet, list on phone
                  return AdaptiveLayout(
                    mobile: _ArticleListView(
                      articles: articles,
                      ctrl: ctrl,
                      refetch: refetch,
                    ),
                    tablet: _ArticleGridView(
                      articles: articles,
                      ctrl: ctrl,
                      refetch: refetch,
                      crossAxisCount: 2,
                    ),
                    desktop: _ArticleGridView(
                      articles: articles,
                      ctrl: ctrl,
                      refetch: refetch,
                      crossAxisCount: 3,
                    ),
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// ────── ARTICLE LIST (Mobile) ──────
class _ArticleListView extends StatelessWidget {
  final List<Article> articles;
  final HomeController ctrl;
  final VoidCallback refetch;

  const _ArticleListView({
    required this.articles,
    required this.ctrl,
    required this.refetch,
  });

  @override
  Widget build(BuildContext context) {
    return RefreshIndicator(
      onRefresh: () async => refetch(),
      color: const Color(0xFF6C63FF),
      child: ListView.builder(
        padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
        itemCount: articles.length,
        itemBuilder: (context, i) => _ArticleCard(
          article: articles[i],
          ctrl: ctrl,
        ),
      ),
    );
  }
}

// ────── ARTICLE GRID (Tablet/Desktop) ──────
class _ArticleGridView extends StatelessWidget {
  final List<Article> articles;
  final HomeController ctrl;
  final VoidCallback refetch;
  final int crossAxisCount;

  const _ArticleGridView({
    required this.articles,
    required this.ctrl,
    required this.refetch,
    this.crossAxisCount = 2,
  });

  @override
  Widget build(BuildContext context) {
    return GridView.builder(
      padding: const EdgeInsets.all(16),
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: crossAxisCount,
        childAspectRatio: 0.85,
        crossAxisSpacing: 12,
        mainAxisSpacing: 12,
      ),
      itemCount: articles.length,
      itemBuilder: (context, i) => _ArticleCard(
        article: articles[i],
        ctrl: ctrl,
        isGrid: true,
      ),
    );
  }
}

// ────── ARTICLE CARD ──────
class _ArticleCard extends StatelessWidget {
  final Article article;
  final HomeController ctrl;
  final bool isGrid;

  const _ArticleCard({
    required this.article,
    required this.ctrl,
    this.isGrid = false,
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => Fuex.push('/detail', extra: article),
      child: Card(
        color: const Color(0xFF1A1A3E),
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
        clipBehavior: Clip.antiAlias,
        margin: isGrid ? EdgeInsets.zero : const EdgeInsets.only(bottom: 12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // Image
            SizedBox(
              height: isGrid ? 120 : 180,
              width: double.infinity,
              child: Image.network(
                article.imageUrl,
                fit: BoxFit.cover,
                errorBuilder: (_, __, ___) => Container(
                  color: const Color(0xFF6C63FF).withOpacity(0.3),
                  child: const Center(
                    child: Icon(Icons.rocket_launch,
                        color: Color(0xFF6C63FF), size: 48),
                  ),
                ),
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(12),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  // Source badge
                  Container(
                    padding:
                        const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
                    decoration: BoxDecoration(
                      color: const Color(0xFF6C63FF).withOpacity(0.2),
                      borderRadius: BorderRadius.circular(8),
                    ),
                    child: Text(
                      article.newsSite,
                      style: TextStyle(
                          color: const Color(0xFF6C63FF), fontSize: 10.sp > 13 ? 13 : 10.sp),
                    ),
                  ),
                  const SizedBox(height: 8),
                  // Title
                  Text(
                    article.title,
                    maxLines: 2,
                    overflow: TextOverflow.ellipsis,
                    style: TextStyle(
                      color: Colors.white,
                      fontSize: 14.sp > 18 ? 18 : 14.sp,
                      fontWeight: FontWeight.w600,
                    ),
                  ),
                  const SizedBox(height: 8),
                  // Bottom row
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      Text(
                        '${article.publishedAt.day}/${article.publishedAt.month}/${article.publishedAt.year}',
                        style: TextStyle(
                            color: const Color(0xFF8892B0), fontSize: 11.sp > 13 ? 13 : 11.sp),
                      ),
                      // Bookmark toggle via Obx!
                      Obx(() => GestureDetector(
                            onTap: () => ctrl.toggleBookmark(article),
                            child: Icon(
                              ctrl.isBookmarked(article.id)
                                  ? Icons.bookmark
                                  : Icons.bookmark_border,
                              color: ctrl.isBookmarked(article.id)
                                  ? const Color(0xFFFFD700)
                                  : const Color(0xFF8892B0),
                              size: 20,
                            ),
                          )),
                    ],
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// ────── DETAIL PAGE ──────
class DetailPage extends StatelessWidget {
  const DetailPage({super.key});

  @override
  Widget build(BuildContext context) {
    final article = Fuex.extra as Article;
    final ctrl = Fuex.find<HomeController>();

    return Scaffold(
      backgroundColor: const Color(0xFF0F0F23),
      body: CustomScrollView(
        slivers: [
          SliverAppBar(
            expandedHeight: 250,
            pinned: true,
            backgroundColor: const Color(0xFF1A1A3E),
            leading: IconButton(
              icon: const Icon(Icons.arrow_back_ios, color: Colors.white),
              onPressed: () => Fuex.pop(),
            ),
            actions: [
              Obx(() => IconButton(
                    icon: Icon(
                      ctrl.isBookmarked(article.id)
                          ? Icons.bookmark
                          : Icons.bookmark_border,
                      color: ctrl.isBookmarked(article.id)
                          ? const Color(0xFFFFD700)
                          : Colors.white,
                    ),
                    onPressed: () => ctrl.toggleBookmark(article),
                  )),
            ],
            flexibleSpace: FlexibleSpaceBar(
              background: Image.network(
                article.imageUrl,
                fit: BoxFit.cover,
                errorBuilder: (_, __, ___) => Container(
                  color: const Color(0xFF6C63FF).withOpacity(0.3),
                ),
              ),
            ),
          ),
          SliverToBoxAdapter(
            child: Padding(
              padding: const EdgeInsets.all(20),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  // Source + Date
                  Row(
                    children: [
                      Container(
                        padding: const EdgeInsets.symmetric(
                            horizontal: 10, vertical: 4),
                        decoration: BoxDecoration(
                          color: const Color(0xFF6C63FF),
                          borderRadius: BorderRadius.circular(8),
                        ),
                        child: Text(article.newsSite,
                            style: const TextStyle(
                                color: Colors.white, fontSize: 12)),
                      ),
                      const SizedBox(width: 12),
                      Text(
                        '${article.publishedAt.day}/${article.publishedAt.month}/${article.publishedAt.year}',
                        style: const TextStyle(color: Color(0xFF8892B0)),
                      ),
                    ],
                  ),
                  const SizedBox(height: 16),
                  // Title
                  Text(
                    article.title,
                    style: TextStyle(
                      color: Colors.white,
                      fontSize: 22.sp > 28 ? 28 : 22.sp,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  const SizedBox(height: 16),
                  // Summary
                  Text(
                    article.summary,
                    style: TextStyle(
                      color: const Color(0xFFCCD6F6),
                      fontSize: 15.sp > 18 ? 18 : 15.sp,
                      height: 1.6,
                    ),
                  ),
                  const SizedBox(height: 24),
                  // useState demo — reading progress
                  _SectionLabel('useState Demo — Reading Tracker'),
                  useState<int>(
                    initial: 0,
                    builder: (context, readCount) => Card(
                      color: const Color(0xFF1A1A3E),
                      shape: RoundedRectangleBorder(
                          borderRadius: BorderRadius.circular(12)),
                      child: Padding(
                        padding: const EdgeInsets.all(16),
                        child: Row(
                          mainAxisAlignment: MainAxisAlignment.spaceBetween,
                          children: [
                            const Text('Times read',
                                style: TextStyle(color: Colors.white)),
                            Row(children: [
                              IconButton(
                                icon: const Icon(Icons.remove_circle_outline,
                                    color: Color(0xFF6C63FF)),
                                onPressed: () => readCount.value--,
                              ),
                              Text('${readCount.value}',
                                  style: const TextStyle(
                                      color: Colors.white,
                                      fontSize: 20,
                                      fontWeight: FontWeight.bold)),
                              IconButton(
                                icon: const Icon(Icons.add_circle_outline,
                                    color: Color(0xFF6C63FF)),
                                onPressed: () => readCount.value++,
                              ),
                            ]),
                          ],
                        ),
                      ),
                    ),
                  ),
                  const SizedBox(height: 16),
                  // useStorage demo — persistent note
                  _SectionLabel('useStorage Demo — Persistent Note'),
                  useStorage<String>(
                    storageKey: 'note_${article.id}',
                    initial: '',
                    builder: (context, note) => Card(
                      color: const Color(0xFF1A1A3E),
                      shape: RoundedRectangleBorder(
                          borderRadius: BorderRadius.circular(12)),
                      child: Padding(
                        padding: const EdgeInsets.all(16),
                        child: TextField(
                          style: const TextStyle(color: Colors.white),
                          decoration: InputDecoration(
                            hintText: 'Write a note about this article...',
                            hintStyle:
                                const TextStyle(color: Color(0xFF8892B0)),
                            filled: true,
                            fillColor: const Color(0xFF0F3460),
                            border: OutlineInputBorder(
                              borderRadius: BorderRadius.circular(12),
                              borderSide: BorderSide.none,
                            ),
                          ),
                          controller:
                              TextEditingController(text: note.value),
                          onChanged: (v) => note.value = v,
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

// ────── BOOKMARKS PAGE ──────
class BookmarksPage extends StatelessWidget {
  const BookmarksPage({super.key});

  @override
  Widget build(BuildContext context) {
    final ctrl = Fuex.find<HomeController>();

    return Scaffold(
      backgroundColor: const Color(0xFF0F0F23),
      appBar: AppBar(
        backgroundColor: const Color(0xFF1A1A3E),
        leading: IconButton(
          icon: const Icon(Icons.arrow_back_ios, color: Colors.white),
          onPressed: () => Fuex.pop(),
        ),
        title: const Text('Bookmarks', style: TextStyle(color: Colors.white)),
      ),
      body: Obx(() {
        if (ctrl.bookmarks.isEmpty) {
          return const Center(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Icon(Icons.bookmark_border,
                    color: Color(0xFF8892B0), size: 64),
                SizedBox(height: 12),
                Text('No bookmarks yet',
                    style: TextStyle(color: Color(0xFF8892B0), fontSize: 16)),
              ],
            ),
          );
        }

        return ListView.builder(
          padding: const EdgeInsets.all(12),
          itemCount: ctrl.bookmarks.length,
          itemBuilder: (context, i) {
            final article = ctrl.bookmarks[i];
            return Dismissible(
              key: ValueKey(article.id),
              direction: DismissDirection.endToStart,
              background: Container(
                alignment: Alignment.centerRight,
                padding: const EdgeInsets.only(right: 20),
                color: const Color(0xFFFF6B6B),
                child: const Icon(Icons.delete, color: Colors.white),
              ),
              onDismissed: (_) => ctrl.toggleBookmark(article),
              child: Card(
                color: const Color(0xFF1A1A3E),
                shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(12)),
                margin: const EdgeInsets.only(bottom: 8),
                child: ListTile(
                  leading: ClipRRect(
                    borderRadius: BorderRadius.circular(8),
                    child: Image.network(
                      article.imageUrl,
                      width: 60,
                      height: 60,
                      fit: BoxFit.cover,
                      errorBuilder: (_, __, ___) => Container(
                        width: 60,
                        height: 60,
                        color: const Color(0xFF6C63FF).withOpacity(0.3),
                        child: const Icon(Icons.rocket_launch,
                            color: Color(0xFF6C63FF)),
                      ),
                    ),
                  ),
                  title: Text(
                    article.title,
                    maxLines: 2,
                    overflow: TextOverflow.ellipsis,
                    style: const TextStyle(color: Colors.white, fontSize: 14),
                  ),
                  subtitle: Text(article.newsSite,
                      style: const TextStyle(
                          color: Color(0xFF6C63FF), fontSize: 11)),
                  trailing: const Icon(Icons.chevron_right,
                      color: Color(0xFF8892B0)),
                  onTap: () => Fuex.push('/detail', extra: article),
                ),
              ),
            );
          },
        );
      }),
    );
  }
}

// ────── NOT FOUND ──────
class NotFoundPage extends StatelessWidget {
  const NotFoundPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF0F0F23),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('404',
                style: TextStyle(
                    color: Color(0xFF6C63FF),
                    fontSize: 72,
                    fontWeight: FontWeight.bold)),
            const Text('Page Not Found',
                style: TextStyle(color: Colors.white, fontSize: 24)),
            const SizedBox(height: 24),
            ElevatedButton(
              style: ElevatedButton.styleFrom(
                  backgroundColor: const Color(0xFF6C63FF)),
              onPressed: () => Fuex.offAll('/'),
              child:
                  const Text('Go Home', style: TextStyle(color: Colors.white)),
            ),
          ],
        ),
      ),
    );
  }
}

// ─────────────────────────────────────────────
// 7. SHARED WIDGETS
// ─────────────────────────────────────────────

class _SectionLabel extends StatelessWidget {
  final String text;
  const _SectionLabel(this.text);

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 8),
      child: Text(text,
          style: const TextStyle(
            color: Color(0xFF6C63FF),
            fontSize: 13,
            fontWeight: FontWeight.bold,
            letterSpacing: 0.5,
          )),
    );
  }
}

// ─────────────────────────────────────────────
// 8. ENTRY POINT
// ─────────────────────────────────────────────

void main() {
  // Init global services (Network permanently injected)
  GlobalBindings().dependencies();

  runApp(
    FuexApp(
      title: 'Fuex News — Sample App',
      initialPath: '/',
      theme: ThemeData.dark().copyWith(
        colorScheme: const ColorScheme.dark(
          primary: Color(0xFF6C63FF),
          surface: Color(0xFF1A1A3E),
        ),
      ),
      notFoundBuilder: (_) => const NotFoundPage(),
      routes: [
        FuexRouteEntry(
          path: '/',
          builder: (_) => const HomePage(),
          binding: HomeBinding(),
        ),
        FuexRouteEntry(
          path: '/detail',
          builder: (_) => const DetailPage(),
        ),
        FuexRouteEntry(
          path: '/bookmarks',
          builder: (_) => const BookmarksPage(),
        ),
      ],
    ),
  );
}
2
likes
140
points
110
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A Flutter meta-framework inspired by Next.js routing, React hooks, and GetX DI. Features folder-based routing, granular reactive state, context-less navigation, useQuery/useMutation, Super Schema validation, and auto garbage collection.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

dio, flutter, shared_preferences

More

Packages that depend on fuex