diff --git a/lib/app_shell.dart b/lib/app_shell.dart new file mode 100644 index 0000000..2533359 --- /dev/null +++ b/lib/app_shell.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +import 'pages/account_page.dart'; +import 'pages/manage_page.dart'; +import 'pages/stats_page.dart'; +import 'pages/study_page.dart'; + +/// 应用外壳:底部四个主入口。 +/// +/// 用 [IndexedStack] 而不是直接切换 widget,是为了在切页时保留各页面的 +/// 状态(滚动位置、输入内容、进行中的学习会话)。 +class AppShell extends StatefulWidget { + const AppShell({super.key}); + + @override + State createState() => _AppShellState(); +} + +class _AppShellState extends State { + int _selectedIndex = 0; + + static const List _pages = [ + StudyPage(), + ManagePage(), + StatsPage(), + AccountPage(), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: IndexedStack(index: _selectedIndex, children: _pages), + bottomNavigationBar: NavigationBar( + selectedIndex: _selectedIndex, + onDestinationSelected: (int index) { + setState(() => _selectedIndex = index); + }, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.school_outlined), + selectedIcon: Icon(Icons.school), + label: '学习', + ), + NavigationDestination( + icon: Icon(Icons.library_books_outlined), + selectedIcon: Icon(Icons.library_books), + label: '管理', + ), + NavigationDestination( + icon: Icon(Icons.insights_outlined), + selectedIcon: Icon(Icons.insights), + label: '统计', + ), + NavigationDestination( + icon: Icon(Icons.person_outline), + selectedIcon: Icon(Icons.person), + label: '账户', + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 244a702..798e88a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,121 +1,35 @@ import 'package:flutter/material.dart'; +import 'app_shell.dart'; + +/// Dart 层的应用名。各平台原生的应用名(窗口标题、桌面图标)由各自的配置 +/// 文件提供:android 的 `app_name` 字符串资源、ios 的 `CFBundleDisplayName`、 +/// linux/windows 的 `APPLICATION_TITLE`,文本与这里保持一致。 +const String appName = 'KiriMemo'; + void main() { - runApp(const MyApp()); + runApp(const KiriMemoApp()); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class KiriMemoApp extends StatelessWidget { + const KiriMemoApp({super.key}); - // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: .fromSeed(seedColor: Colors.deepPurple), - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), + title: appName, + debugShowCheckedModeBanner: false, + theme: _theme(Brightness.light), + darkTheme: _theme(Brightness.dark), + home: const AppShell(), ); } -} -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: .center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), + static ThemeData _theme(Brightness brightness) { + return ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF4C6EF5), + brightness: brightness, ), ); } diff --git a/lib/pages/account_page.dart b/lib/pages/account_page.dart new file mode 100644 index 0000000..641ae8f --- /dev/null +++ b/lib/pages/account_page.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +import '../widgets/empty_state.dart'; + +/// 账户:登录、同步与偏好设置。 +class AccountPage extends StatelessWidget { + const AccountPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('账户')), + body: const EmptyState( + icon: Icons.person_outline, + title: '账户与同步', + message: '登录后可以在多台设备之间同步学习记录。', + ), + ); + } +} diff --git a/lib/pages/manage_page.dart b/lib/pages/manage_page.dart new file mode 100644 index 0000000..ca89623 --- /dev/null +++ b/lib/pages/manage_page.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +import '../widgets/empty_state.dart'; + +/// 管理:牌组与卡片的增删改查。 +class ManagePage extends StatelessWidget { + const ManagePage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('管理')), + body: const EmptyState( + icon: Icons.library_books_outlined, + title: '管理卡片与牌组', + message: '在这里添加、编辑和整理你的学习材料。', + ), + ); + } +} diff --git a/lib/pages/stats_page.dart b/lib/pages/stats_page.dart new file mode 100644 index 0000000..0a798c2 --- /dev/null +++ b/lib/pages/stats_page.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +import '../widgets/empty_state.dart'; + +/// 统计:学习进度、记忆保持率与每日复习量。 +class StatsPage extends StatelessWidget { + const StatsPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('统计')), + body: const EmptyState( + icon: Icons.insights_outlined, + title: '复习数据统计', + message: '学习进度、记忆保持率和每日复习量会显示在这里。', + ), + ); + } +} diff --git a/lib/pages/study_page.dart b/lib/pages/study_page.dart new file mode 100644 index 0000000..bab0c86 --- /dev/null +++ b/lib/pages/study_page.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +import '../widgets/empty_state.dart'; + +/// 学习:今天到期的卡片与复习会话入口。 +class StudyPage extends StatelessWidget { + const StudyPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('学习')), + body: const EmptyState( + icon: Icons.school_outlined, + title: '开始今天的复习', + message: '待学习的卡片和到期需要复习的卡片会出现在这里。', + ), + ); + } +} diff --git a/lib/widgets/empty_state.dart b/lib/widgets/empty_state.dart new file mode 100644 index 0000000..b02f779 --- /dev/null +++ b/lib/widgets/empty_state.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +/// 各主页面共用的占位内容:一个图标、一句标题和可选说明。 +/// +/// 页面接上真实业务后可以直接替换掉。 +class EmptyState extends StatelessWidget { + const EmptyState({ + required this.icon, + required this.title, + this.message, + super.key, + }); + + final IconData icon; + final String title; + final String? message; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final String? message = this.message; + + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 56, color: theme.colorScheme.primary), + const SizedBox(height: 16), + Text( + title, + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + if (message != null) ...[ + const SizedBox(height: 8), + Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ], + ), + ), + ); + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index a6b7d51..8f5bb04 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,30 +1,44 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mobile/main.dart'; +/// 在导航栏内按标签定位,避免和页面标题的相同文字混淆。 +Finder navLabel(String label) { + return find.descendant( + of: find.byType(NavigationBar), + matching: find.text(label), + ); +} + void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + testWidgets('底部导航提供学习 / 管理 / 统计 / 账户四个入口', (WidgetTester tester) async { + await tester.pumpWidget(const KiriMemoApp()); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.byType(NavigationDestination), findsNWidgets(4)); - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); + for (final String label in ['学习', '管理', '统计', '账户']) { + expect(navLabel(label), findsOneWidget); + } + }); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + testWidgets('默认停留在学习页,点击其它入口会切换', (WidgetTester tester) async { + await tester.pumpWidget(const KiriMemoApp()); + + NavigationBar bar = tester.widget(find.byType(NavigationBar)); + expect(bar.selectedIndex, 0); + + await tester.tap(navLabel('统计')); + await tester.pumpAndSettle(); + + bar = tester.widget(find.byType(NavigationBar)); + expect(bar.selectedIndex, 2); + + await tester.tap(navLabel('账户')); + await tester.pumpAndSettle(); + + bar = tester.widget(find.byType(NavigationBar)); + expect(bar.selectedIndex, 3); }); }