fuex 0.2.0
fuex: ^0.2.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 [...]
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(),
),
],
),
);
}