chore: bootstrap flutter reader app skeleton

This commit is contained in:
2026-03-23 15:57:38 +07:00
parent f5e7813548
commit 4f202936fa
150 changed files with 6278 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/theme/app_theme.dart';
import 'router/app_router.dart';
class ReaderApp extends ConsumerWidget {
const ReaderApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(appRouterProvider);
return MaterialApp.router(
title: 'Reader App',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
routerConfig: router,
);
}
}
+80
View File
@@ -0,0 +1,80 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../features/auth/presentation/login_screen.dart';
import '../../features/bookshelf/presentation/bookshelf_screen.dart';
import '../../features/comments/presentation/comments_screen.dart';
import '../../features/genres/presentation/genres_screen.dart';
import '../../features/home/presentation/home_screen.dart';
import '../../features/novel/presentation/novel_detail_screen.dart';
import '../../features/profile/presentation/profile_screen.dart';
import '../../features/reader/presentation/reader_screen.dart';
import '../../features/search/presentation/search_screen.dart';
import '../../features/settings/presentation/settings_screen.dart';
import '../../features/splash/presentation/splash_screen.dart';
import '../../shared/widgets/app_shell.dart';
import 'route_names.dart';
final appRouterProvider = Provider<GoRouter>((ref) {
return GoRouter(
initialLocation: RouteNames.splash,
routes: [
GoRoute(
path: RouteNames.splash,
builder: (context, state) => const SplashScreen(),
),
GoRoute(
path: RouteNames.login,
builder: (context, state) => const LoginScreen(),
),
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
GoRoute(
path: RouteNames.home,
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: RouteNames.search,
builder: (context, state) => const SearchScreen(),
),
GoRoute(
path: RouteNames.genres,
builder: (context, state) => const GenresScreen(),
),
GoRoute(
path: RouteNames.bookshelf,
builder: (context, state) => const BookshelfScreen(),
),
GoRoute(
path: RouteNames.profile,
builder: (context, state) => const ProfileScreen(),
),
],
),
GoRoute(
path: RouteNames.novelDetail,
builder: (_, state) => NovelDetailScreen(
novelId: state.uri.queryParameters['id'] ?? '',
),
),
GoRoute(
path: RouteNames.reader,
builder: (_, state) => ReaderScreen(
chapterId: state.uri.queryParameters['chapterId'] ?? '',
),
),
GoRoute(
path: RouteNames.comments,
builder: (_, state) => CommentsScreen(
novelId: state.uri.queryParameters['novelId'] ?? '',
chapterId: state.uri.queryParameters['chapterId'],
),
),
GoRoute(
path: RouteNames.settings,
builder: (context, state) => const SettingsScreen(),
),
],
);
});
+15
View File
@@ -0,0 +1,15 @@
class RouteNames {
RouteNames._();
static const splash = '/';
static const home = '/home';
static const login = '/login';
static const search = '/search';
static const genres = '/genres';
static const novelDetail = '/novel';
static const reader = '/reader';
static const bookshelf = '/bookshelf';
static const comments = '/comments';
static const profile = '/profile';
static const settings = '/settings';
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:dio/dio.dart';
import '../storage/secure_store.dart';
class ApiClient {
ApiClient({
required String baseUrl,
required SecureStore secureStore,
}) : _secureStore = secureStore,
dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(seconds: 20),
headers: const {'Content-Type': 'application/json'},
),
) {
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await _secureStore.getAccessToken();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
),
);
}
final Dio dio;
final SecureStore _secureStore;
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:shared_preferences/shared_preferences.dart';
class LocalStore {
static const _kFontSize = 'reader_font_size';
static const _kLineHeight = 'reader_line_height';
static const _kLetterSpacing = 'reader_letter_spacing';
static const _kFontFamily = 'reader_font_family';
Future<void> saveReadingSettings({
required double fontSize,
required double lineHeight,
required double letterSpacing,
required String fontFamily,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_kFontSize, fontSize);
await prefs.setDouble(_kLineHeight, lineHeight);
await prefs.setDouble(_kLetterSpacing, letterSpacing);
await prefs.setString(_kFontFamily, fontFamily);
}
Future<Map<String, dynamic>> getReadingSettings() async {
final prefs = await SharedPreferences.getInstance();
return {
'fontSize': prefs.getDouble(_kFontSize) ?? 18,
'lineHeight': prefs.getDouble(_kLineHeight) ?? 1.8,
'letterSpacing': prefs.getDouble(_kLetterSpacing) ?? 0,
'fontFamily': prefs.getString(_kFontFamily) ?? 'serif',
};
}
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class SecureStore {
SecureStore() : _storage = const FlutterSecureStorage();
static const _kAccessToken = 'access_token';
static const _kRefreshToken = 'refresh_token';
final FlutterSecureStorage _storage;
Future<void> setAccessToken(String token) =>
_storage.write(key: _kAccessToken, value: token);
Future<String?> getAccessToken() => _storage.read(key: _kAccessToken);
Future<void> setRefreshToken(String token) =>
_storage.write(key: _kRefreshToken, value: token);
Future<String?> getRefreshToken() => _storage.read(key: _kRefreshToken);
Future<void> clear() => _storage.deleteAll();
}
+19
View File
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
class AppTheme {
AppTheme._();
static final ThemeData lightTheme = ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF155DFC),
brightness: Brightness.light,
),
scaffoldBackgroundColor: const Color(0xFFF7F9FC),
appBarTheme: const AppBarTheme(
centerTitle: false,
backgroundColor: Color(0xFFF7F9FC),
foregroundColor: Color(0xFF121826),
),
);
}
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import '../../../shared/widgets/feature_placeholder.dart';
class LoginScreen extends StatelessWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Dang nhap')),
body: const FeaturePlaceholder(
title: 'Google Login',
description:
'Khung dang nhap Google OAuth cho mobile auth endpoint. Se bo sung token refresh va secure storage trong phase tiep theo.',
),
);
}
}
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import '../../../shared/widgets/feature_placeholder.dart';
class BookshelfScreen extends StatelessWidget {
const BookshelfScreen({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 4,
child: Scaffold(
appBar: AppBar(
title: const Text('Tu sach'),
bottom: const TabBar(
isScrollable: true,
tabs: [
Tab(text: 'Dang doc'),
Tab(text: 'Danh dau'),
Tab(text: 'Da doc'),
Tab(text: 'De cu'),
],
),
),
body: const TabBarView(
children: [
FeaturePlaceholder(
title: 'Dang doc',
description: 'Danh sach truyện dang doc theo progress sync.',
),
FeaturePlaceholder(
title: 'Danh dau',
description: 'Tat ca truyện da bookmark cua user.',
),
FeaturePlaceholder(
title: 'Da doc',
description: 'Danh sach truyện da hoan thanh.',
),
FeaturePlaceholder(
title: 'De cu',
description: 'Danh sach truyện user da de cu.',
),
],
),
),
);
}
}
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
class CommentsScreen extends StatelessWidget {
const CommentsScreen({
super.key,
required this.novelId,
this.chapterId,
});
final String novelId;
final String? chapterId;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Binh luan')),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
Text('Novel ID: ${novelId.isEmpty ? '(missing)' : novelId}'),
Text('Chapter ID: ${chapterId ?? '(all novel comments)'}'),
const SizedBox(height: 12),
const Text('Khung danh sach binh luan + form gui comment + phan trang.'),
const SizedBox(height: 20),
TextField(
maxLines: 4,
decoration: const InputDecoration(
hintText: 'Viet binh luan cua ban...',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: () {},
child: const Text('Gui binh luan'),
),
],
),
);
}
}
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import '../../../shared/widgets/feature_placeholder.dart';
class GenresScreen extends StatelessWidget {
const GenresScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('The loai')),
body: const FeaturePlaceholder(
title: 'Genre Discovery',
description:
'Khung danh sach the loai va man hinh truyện theo the loai slug de dong bo hanh vi voi web.',
),
);
}
}
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart';
import '../../../shared/widgets/feature_placeholder.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Trang chu')),
body: FeaturePlaceholder(
title: 'Home Feed',
description:
'Khung trang chu cho carousel hot, random grid, bang de cu, bang xep hang, truyện moi cap nhat va comments gan day.',
actions: [
FilledButton(
onPressed: () => context.go(RouteNames.search),
child: const Text('Mo tim kiem'),
),
],
),
);
}
}
@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart';
class NovelDetailScreen extends StatelessWidget {
const NovelDetailScreen({super.key, required this.novelId});
final String novelId;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Chi tiet truyện')),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
Text('Novel ID: ${novelId.isEmpty ? '(missing)' : novelId}'),
const SizedBox(height: 12),
const Text(
'Khung chi tiet truyện: metadata, series, chapter list, rating, bookmark, recommendation, comments.',
),
const SizedBox(height: 20),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
FilledButton(
onPressed: () => context.push('${RouteNames.reader}?chapterId=1'),
child: const Text('Doc chuong 1'),
),
OutlinedButton(
onPressed: () =>
context.push('${RouteNames.comments}?novelId=$novelId'),
child: const Text('Xem binh luan'),
),
],
),
],
),
);
}
}
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart';
import '../../../shared/widgets/feature_placeholder.dart';
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Tai khoan')),
body: FeaturePlaceholder(
title: 'User Profile',
description:
'Khung profile user, thong tin session, thong ke bookmark/de cu va cac cai dat doc dong bo.',
actions: [
FilledButton(
onPressed: () => context.push(RouteNames.settings),
child: const Text('Mo cai dat doc'),
),
],
),
);
}
}
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart';
class ReaderScreen extends StatefulWidget {
const ReaderScreen({super.key, required this.chapterId});
final String chapterId;
@override
State<ReaderScreen> createState() => _ReaderScreenState();
}
class _ReaderScreenState extends State<ReaderScreen> {
double fontSize = 18;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Doc chuong ${widget.chapterId.isEmpty ? '?' : widget.chapterId}'),
actions: [
IconButton(
onPressed: () => context.push(RouteNames.settings),
icon: const Icon(Icons.tune),
tooltip: 'Cai dat doc',
),
],
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Reader body placeholder with TOC, TTS, offline marker.'),
const SizedBox(height: 12),
Text('Co chu hien tai: ${fontSize.toStringAsFixed(0)}'),
Slider(
min: 14,
max: 26,
value: fontSize,
onChanged: (v) => setState(() => fontSize = v),
),
const Spacer(),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {},
child: const Text('Chuong truoc'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () {},
child: const Text('Chuong sau'),
),
),
],
),
],
),
),
floatingActionButton: FloatingActionButton.small(
onPressed: () {},
child: const Icon(Icons.record_voice_over),
),
);
}
}
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import '../../../shared/widgets/feature_placeholder.dart';
class SearchScreen extends StatelessWidget {
const SearchScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Tim kiem')),
body: const FeaturePlaceholder(
title: 'Search + Filters',
description:
'Khung tim kiem truyện voi goi y theo tu khoa, loc theo the loai/trang thai va sap xep theo views-rating-latest.',
),
);
}
}
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
double fontSize = 18;
double lineHeight = 1.8;
double letterSpacing = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Cai dat doc')),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
Text('Co chu: ${fontSize.toStringAsFixed(0)}'),
Slider(
min: 14,
max: 26,
value: fontSize,
onChanged: (v) => setState(() => fontSize = v),
),
const SizedBox(height: 12),
Text('Line-height: ${lineHeight.toStringAsFixed(1)}'),
Slider(
min: 1.2,
max: 2.4,
value: lineHeight,
onChanged: (v) => setState(() => lineHeight = v),
),
const SizedBox(height: 12),
Text('Letter-spacing: ${letterSpacing.toStringAsFixed(1)}'),
Slider(
min: -0.5,
max: 2,
value: letterSpacing,
onChanged: (v) => setState(() => letterSpacing = v),
),
const SizedBox(height: 24),
FilledButton(
onPressed: () {},
child: const Text('Luu va dong bo'),
),
],
),
);
}
}
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
@override
void initState() {
super.initState();
Future<void>.delayed(const Duration(milliseconds: 700), () {
if (!mounted) return;
context.go(RouteNames.home);
});
}
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.menu_book_rounded, size: 48),
SizedBox(height: 12),
Text('Reader App'),
],
),
),
);
}
}
+9
View File
@@ -0,0 +1,9 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app/app.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const ProviderScope(child: ReaderApp()));
}
+52
View File
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../app/router/route_names.dart';
class AppShell extends StatelessWidget {
const AppShell({super.key, required this.child});
final Widget child;
int _indexForLocation(String location) {
if (location.startsWith(RouteNames.search)) return 1;
if (location.startsWith(RouteNames.bookshelf)) return 2;
if (location.startsWith(RouteNames.genres)) return 3;
if (location.startsWith(RouteNames.profile)) return 4;
return 0;
}
@override
Widget build(BuildContext context) {
final location = GoRouterState.of(context).uri.path;
final selectedIndex = _indexForLocation(location);
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: selectedIndex,
onDestinationSelected: (index) {
switch (index) {
case 0:
context.go(RouteNames.home);
case 1:
context.go(RouteNames.search);
case 2:
context.go(RouteNames.bookshelf);
case 3:
context.go(RouteNames.genres);
case 4:
context.go(RouteNames.profile);
}
},
destinations: const [
NavigationDestination(icon: Icon(Icons.home_outlined), label: 'Home'),
NavigationDestination(icon: Icon(Icons.search), label: 'Tim kiem'),
NavigationDestination(icon: Icon(Icons.bookmark_border), label: 'Tu sach'),
NavigationDestination(icon: Icon(Icons.category_outlined), label: 'The loai'),
NavigationDestination(icon: Icon(Icons.person_outline), label: 'Tai khoan'),
],
),
);
}
}
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
class FeaturePlaceholder extends StatelessWidget {
const FeaturePlaceholder({
super.key,
required this.title,
required this.description,
this.actions = const <Widget>[],
});
final String title;
final String description;
final List<Widget> actions;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: Card(
elevation: 0,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.headlineSmall),
const SizedBox(height: 10),
Text(description, style: theme.textTheme.bodyLarge),
if (actions.isNotEmpty) ...[
const SizedBox(height: 18),
Wrap(spacing: 10, runSpacing: 10, children: actions),
],
],
),
),
),
),
),
);
}
}