Flutter for OpenHarmony 学习答题应用实战开发

开源鸿蒙跨平台社区

欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net


前言

随着 OpenHarmony 生态的蓬勃发展,Flutter 作为跨平台开发框架也在积极适配鸿蒙平台。本文将通过一个完整的学习答题应用实例,带领大家掌握 Flutter for OpenHarmony 的开发流程,体验跨平台开发的魅力。

作者:maaath


一、项目概述

1.1 项目背景

学习答题应用是教育类应用中的经典场景,涵盖了题库管理、答题交互、错题回顾、考试测评等核心功能。通过这个项目,我们可以深入学习 Flutter 的状态管理、网络请求、页面导航等核心能力。

1.2 技术选型

  • 跨平台框架:Flutter 3.x (适配 OpenHarmony)
  • 状态管理:Provider
  • 网络请求:dio
  • 本地存储:shared_preferences
  • 目标平台:Android / iOS / OpenHarmony

1.3 功能架构

学习答题应用
├── 题库分类列表
├── 答题练习模块
│   ├── 单选题
│   ├── 多选题
│   └── 判断题
├── 错题本管理
├── 模拟考试系统
└ └── 个人中心

二、项目结构

首先,我们来看一下项目的目录结构:

lib/
├── main.dart                    # 应用入口
├── models/                      # 数据模型
│   ├── question_model.dart
│   ├── category_model.dart
│   └── exam_model.dart
├── services/                   # 网络服务
│   ├── api_service.dart
│   └── storage_service.dart
├── providers/                  # 状态管理
│   ├── quiz_provider.dart
│   └── user_provider.dart
├── pages/                      # 页面
│   ├── home_page.dart
│   ├── quiz_page.dart
│   ├── wrong_book_page.dart
│   ├── exam_page.dart
│   └── profile_page.dart
├── widgets/                    # 通用组件
│   ├── question_card.dart
│   ├── option_item.dart
│   └── result_dialog.dart
└── utils/                      # 工具类
    ├── constants.dart
    └── theme.dart

三、核心数据模型

3.1 题目模型

题目是答题应用的核心数据结构,我们需要支持多种题型:

// lib/models/question_model.dart

/// 题目类型枚举
enum QuestionType {
  single,    // 单选题
  multiple,   // 多选题
  trueFalse,  // 判断题
  fill,       // 填空题
}

/// 难度等级
enum Difficulty {
  easy,    // 简单
  medium,  // 中等
  hard,    // 困难
}

/// 题目选项
class QuestionOption {
  final String id;
  final String label;
  final String content;
  
  QuestionOption({
    required this.id,
    required this.label,
    required this.content,
  });
  
  factory QuestionOption.fromJson(Map<String, dynamic> json) {
    return QuestionOption(
      id: json['id'] as String,
      label: json['label'] as String,
      content: json['content'] as String,
    );
  }
  
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'label': label,
      'content': content,
    };
  }
}

/// 题目模型
class Question {
  final int id;
  final int categoryId;
  final QuestionType type;
  final Difficulty difficulty;
  final String question;
  final List<QuestionOption>? options;
  final dynamic correctAnswer; // string 或 List<String>
  final String explanation;
  
  Question({
    required this.id,
    required this.categoryId,
    required this.type,
    required this.difficulty,
    required this.question,
    this.options,
    required this.correctAnswer,
    required this.explanation,
  });
  
  factory Question.fromJson(Map<String, dynamic> json) {
    return Question(
      id: json['id'] as int,
      categoryId: json['categoryId'] as int,
      type: QuestionType.values[json['type'] as int],
      difficulty: Difficulty.values[json['difficulty'] as int],
      question: json['question'] as String,
      options: json['options'] != null
          ? (json['options'] as List)
              .map((e) => QuestionOption.fromJson(e as Map<String, dynamic>))
              .toList()
          : null,
      correctAnswer: json['correctAnswer'],
      explanation: json['explanation'] as String,
    );
  }
  
  bool checkAnswer(dynamic userAnswer) {
    if (correctAnswer is String && userAnswer is String) {
      return correctAnswer == userAnswer;
    }
    if (correctAnswer is List && userAnswer is List) {
      if (correctAnswer.length != userAnswer.length) return false;
      return correctAnswer.every((e) => userAnswer.contains(e));
    }
    return false;
  }
}

3.2 题库分类模型

// lib/models/category_model.dart

/// 题库分类
class Category {
  final int id;
  final String name;
  final String icon;
  final String color;
  final String description;
  final int questionCount;
  final int completedCount;
  
  double get progressRate => 
      questionCount > 0 ? completedCount / questionCount : 0;
  
  Category({
    required this.id,
    required this.name,
    required this.icon,
    required this.color,
    required this.description,
    required this.questionCount,
    required this.completedCount,
  });
  
  factory Category.fromJson(Map<String, dynamic> json) {
    return Category(
      id: json['id'] as int,
      name: json['name'] as String,
      icon: json['icon'] as String,
      color: json['color'] as String,
      description: json['description'] as String,
      questionCount: json['questionCount'] as int,
      completedCount: json['completedCount'] as int,
    );
  }
}

四、网络服务层

4.1 API 服务封装

使用 dio 进行网络请求封装,支持统一错误处理和拦截器:

// lib/services/api_service.dart

import 'package:dio/dio.dart';

/// API 服务封装
class ApiService {
  static final ApiService _instance = ApiService._internal();
  factory ApiService() => _instance;
  
  late final Dio _dio;
  
  ApiService._internal() {
    _dio = Dio(BaseOptions(
      baseUrl: 'https://api.example.com',
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 10),
      headers: {
        'Content-Type': 'application/json',
      },
    ));
    
    // 添加拦截器
    _dio.interceptors.add(InterceptorsWrapper(
      onRequest: (options, handler) {
        // 添加 token
        // options.headers['Authorization'] = 'Bearer $token';
        handler.next(options);
      },
      onResponse: (response, handler) {
        handler.next(response);
      },
      onError: (error, handler) {
        // 统一错误处理
        handler.next(error);
      },
    ));
  }
  
  /// GET 请求
  Future<Response<T>> get<T>(
    String path, {
    Map<String, dynamic>? queryParameters,
  }) async {
    return _dio.get<T>(path, queryParameters: queryParameters);
  }
  
  /// POST 请求
  Future<Response<T>> post<T>(
    String path, {
    dynamic data,
  }) async {
    return _dio.post<T>(path, data: data);
  }
}

4.2 题库数据服务

// lib/services/quiz_service.dart

import '../models/question_model.dart';
import '../models/category_model.dart';
import 'api_service.dart';

/// 题库数据服务
class QuizService {
  final ApiService _api = ApiService();
  
  /// 获取题库分类
  Future<List<Category>> getCategories() async {
    // 模拟数据,实际项目中替换为真实 API 调用
    await Future.delayed(const Duration(milliseconds: 500));
    
    return [
      Category(
        id: 1,
        name: '语文',
        icon: '📖',
        color: '#FF6B6B',
        description: '文学常识、古诗词、文言文',
        questionCount: 256,
        completedCount: 120,
      ),
      Category(
        id: 2,
        name: '数学',
        icon: '🔢',
        color: '#4ECDC4',
        description: '代数、几何、概率统计',
        questionCount: 320,
        completedCount: 85,
      ),
      Category(
        id: 3,
        name: '英语',
        icon: '🔤',
        color: '#45B7D1',
        description: '词汇、语法、阅读理解',
        questionCount: 400,
        completedCount: 200,
      ),
      Category(
        id: 4,
        name: '物理',
        icon: '⚡',
        color: '#96CEB4',
        description: '力学、电学、光学、热学',
        questionCount: 280,
        completedCount: 65,
      ),
      Category(
        id: 5,
        name: '化学',
        icon: '🧪',
        color: '#DDA0DD',
        description: '元素周期表、化学反应',
        questionCount: 240,
        completedCount: 45,
      ),
      Category(
        id: 6,
        name: '历史',
        icon: '📜',
        color: '#DEB887',
        description: '中国古代史、世界史',
        questionCount: 350,
        completedCount: 180,
      ),
    ];
  }
  
  /// 获取题目列表
  Future<List<Question>> getQuestions(int categoryId, {int page = 0, int pageSize = 10}) async {
    await Future.delayed(const Duration(milliseconds: 500));
    
    // 生成模拟题目数据
    return _generateMockQuestions(categoryId, page * pageSize, pageSize);
  }
  
  List<Question> _generateMockQuestions(int categoryId, int start, int count) {
    final List<Question> questions = [];
    for (int i = 0; i < count; i++) {
      questions.add(Question(
        id: categoryId * 1000 + start + i,
        categoryId: categoryId,
        type: QuestionType.values[i % 3],
        difficulty: Difficulty.values[i % 3],
        question: '这是第 ${start + i + 1} 题,${_getCategoryName(categoryId)} 相关题目。请问以下哪个选项是正确的?',
        options: [
          QuestionOption(id: 'A', label: 'A', content: '选项 A 的内容'),
          QuestionOption(id: 'B', label: 'B', content: '选项 B 的内容'),
          QuestionOption(id: 'C', label: 'C', content: '选项 C 的内容'),
          QuestionOption(id: 'D', label: 'D', content: '选项 D 的内容'),
        ],
        correctAnswer: ['A', 'B'][i % 2],
        explanation: '本题考察的是 ${_getCategoryName(categoryId)} 的基础知识,正确答案是 B。',
      ));
    }
    return questions;
  }
  
  String _getCategoryName(int categoryId) {
    const names = ['', '语文', '数学', '英语', '物理', '化学', '历史'];
    return names[categoryId] ?? '综合';
  }
}

五、状态管理

5.1 答题状态管理

使用 Provider 进行状态管理,实现答题逻辑:

// lib/providers/quiz_provider.dart

import 'package:flutter/foundation.dart';
import '../models/question_model.dart';
import '../models/category_model.dart';
import '../services/quiz_service.dart';

/// 答题状态
enum QuizStatus {
  loading,
  ready,
  answering,
  submitted,
  completed,
}

/// 答题提供者
class QuizProvider extends ChangeNotifier {
  final QuizService _quizService = QuizService();
  
  // 分类列表
  List<Category> _categories = [];
  List<Category> get categories => _categories;
  
  // 当前题目
  List<Question> _questions = [];
  List<Question> get questions => _questions;
  
  int _currentIndex = 0;
  int get currentIndex => _currentIndex;
  Question? get currentQuestion => 
      _questions.isNotEmpty && _currentIndex < _questions.length 
          ? _questions[_currentIndex] 
          : null;
  
  // 答题状态
  QuizStatus _status = QuizStatus.loading;
  QuizStatus get status => _status;
  
  // 用户答案
  final Map<int, dynamic> _userAnswers = {};
  dynamic getUserAnswer(int questionId) => _userAnswers[questionId];
  
  // 答题结果
  final List<bool> _results = [];
  List<bool> get results => _results;
  
  int get correctCount => _results.where((r) => r).length;
  double get correctRate => 
      _results.isNotEmpty ? correctCount / _results.length : 0;
  
  // 加载分类
  Future<void> loadCategories() async {
    _status = QuizStatus.loading;
    notifyListeners();
    
    try {
      _categories = await _quizService.getCategories();
      _status = QuizStatus.ready;
    } catch (e) {
      _status = QuizStatus.ready;
    }
    notifyListeners();
  }
  
  // 加载题目
  Future<void> loadQuestions(int categoryId) async {
    _status = QuizStatus.loading;
    _currentIndex = 0;
    _userAnswers.clear();
    _results.clear();
    notifyListeners();
    
    try {
      _questions = await _quizService.getQuestions(categoryId);
      _status = QuizStatus.ready;
    } catch (e) {
      _status = QuizStatus.ready;
    }
    notifyListeners();
  }
  
  // 选择答案
  void selectAnswer(String optionId) {
    if (_status != QuizStatus.ready) return;
    
    final question = currentQuestion;
    if (question == null) return;
    
    if (question.type == QuestionType.multiple) {
      // 多选题:切换选择状态
      final current = _userAnswers[question.id];
      if (current == null) {
        _userAnswers[question.id] = [optionId];
      } else if (current is List && current.contains(optionId)) {
        _userAnswers[question.id] = current.where((e) => e != optionId).toList();
      } else {
        _userAnswers[question.id] = [...current, optionId];
      }
    } else {
      // 单选题/判断题:直接设置
      _userAnswers[question.id] = optionId;
    }
    
    _status = QuizStatus.answering;
    notifyListeners();
  }
  
  // 提交答案
  void submitAnswer() {
    if (_status != QuizStatus.answering) return;
    
    final question = currentQuestion;
    if (question == null) return;
    
    final userAnswer = _userAnswers[question.id];
    final isCorrect = question.checkAnswer(userAnswer);
    _results.add(isCorrect);
    
    _status = QuizStatus.submitted;
    notifyListeners();
  }
  
  // 下一题
  void nextQuestion() {
    if (_currentIndex < _questions.length - 1) {
      _currentIndex++;
      _status = QuizStatus.ready;
      notifyListeners();
    } else {
      _status = QuizStatus.completed;
      notifyListeners();
    }
  }
  
  // 重置答题
  void reset() {
    _currentIndex = 0;
    _userAnswers.clear();
    _results.clear();
    _status = QuizStatus.ready;
    notifyListeners();
  }
}

六、页面实现

6.1 首页 - 题库分类

// lib/pages/home_page.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/quiz_provider.dart';
import '../models/category_model.dart';
import 'quiz_page.dart';

/// 首页 - 题库分类列表
class HomePage extends StatefulWidget {
  const HomePage({super.key});

  
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      context.read<QuizProvider>().loadCategories();
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFFF8FAFC),
      body: SafeArea(
        child: Column(
          children: [
            _buildHeader(),
            Expanded(child: _buildCategoryList()),
          ],
        ),
      ),
    );
  }

  Widget _buildHeader() {
    return Container(
      padding: const EdgeInsets.all(16),
      child: Row(
        children: [
          const Text(
            '📚 题库分类',
            style: TextStyle(
              fontSize: 24,
              fontWeight: FontWeight.bold,
              color: Color(0xFF1F2937),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildCategoryList() {
    return Consumer<QuizProvider>(
      builder: (context, provider, _) {
        if (provider.status == QuizStatus.loading) {
          return const Center(
            child: CircularProgressIndicator(),
          );
        }

        return RefreshIndicator(
          onRefresh: () => provider.loadCategories(),
          child: ListView.builder(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            itemCount: provider.categories.length,
            itemBuilder: (context, index) {
              return _CategoryCard(
                category: provider.categories[index],
                onTap: () => _navigateToQuiz(provider.categories[index]),
              );
            },
          ),
        );
      },
    );
  }

  void _navigateToQuiz(Category category) {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (_) => QuizPage(category: category),
      ),
    );
  }
}

/// 分类卡片组件
class _CategoryCard extends StatelessWidget {
  final Category category;
  final VoidCallback onTap;

  const _CategoryCard({
    required this.category,
    required this.onTap,
  });

  
  Widget build(BuildContext context) {
    final color = _parseColor(category.color);

    return GestureDetector(
      onTap: onTap,
      child: Container(
        margin: const EdgeInsets.only(bottom: 12),
        padding: const EdgeInsets.all(16),
        decoration: BoxDecoration(
          color: Colors.white,
          borderRadius: BorderRadius.circular(12),
          boxShadow: [
            BoxShadow(
              color: Colors.black.withOpacity(0.05),
              blurRadius: 4,
              offset: const Offset(0, 2),
            ),
          ],
        ),
        child: Row(
          children: [
            // 图标
            Container(
              width: 56,
              height: 56,
              decoration: BoxDecoration(
                color: color.withOpacity(0.2),
                borderRadius: BorderRadius.circular(12),
              ),
              child: Center(
                child: Text(
                  category.icon,
                  style: const TextStyle(fontSize: 28),
                ),
              ),
            ),
            const SizedBox(width: 12),
            // 内容
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    category.name,
                    style: const TextStyle(
                      fontSize: 16,
                      fontWeight: FontWeight.w500,
                      color: Color(0xFF1F2937),
                    ),
                  ),
                  const SizedBox(height: 4),
                  Text(
                    category.description,
                    style: const TextStyle(
                      fontSize: 12,
                      color: Color(0xFF6B7280),
                    ),
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                  ),
                  const SizedBox(height: 8),
                  // 进度条
                  Row(
                    children: [
                      Expanded(
                        child: ClipRRect(
                          borderRadius: BorderRadius.circular(2),
                          child: LinearProgressIndicator(
                            value: category.progressRate,
                            backgroundColor: const Color(0xFFE5E7EB),
                            valueColor: AlwaysStoppedAnimation<Color>(color),
                            minHeight: 4,
                          ),
                        ),
                      ),
                      const SizedBox(width: 8),
                      Text(
                        '${category.completedCount}/${category.questionCount}',
                        style: const TextStyle(
                          fontSize: 10,
                          color: Color(0xFF9CA3AF),
                        ),
                      ),
                    ],
                  ),
                ],
              ),
            ),
            const Icon(
              Icons.chevron_right,
              color: Color(0xFF9CA3AF),
            ),
          ],
        ),
      ),
    );
  }

  Color _parseColor(String colorStr) {
    try {
      final hex = colorStr.replaceFirst('#', '');
      return Color(int.parse('FF$hex', radix: 16));
    } catch (e) {
      return const Color(0xFF6366F1);
    }
  }
}

6.2 答题页面

// lib/pages/quiz_page.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/quiz_provider.dart';
import '../models/question_model.dart';
import '../models/category_model.dart';

/// 答题页面
class QuizPage extends StatefulWidget {
  final Category category;

  const QuizPage({super.key, required this.category});

  
  State<QuizPage> createState() => _QuizPageState();
}

class _QuizPageState extends State<QuizPage> with SingleTickerProviderStateMixin {
  late AnimationController _animationController;
  late Animation<double> _scaleAnimation;

  
  void initState() {
    super.initState();
    
    _animationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
    );
    _scaleAnimation = Tween<double>(begin: 1.0, end: 0.95).animate(
      CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
    );

    WidgetsBinding.instance.addPostFrameCallback((_) {
      context.read<QuizProvider>().loadQuestions(widget.category.id);
    });
  }

  
  void dispose() {
    _animationController.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    final color = _parseColor(widget.category.color);

    return Scaffold(
      backgroundColor: const Color(0xFFF8FAFC),
      appBar: AppBar(
        backgroundColor: Colors.white,
        elevation: 0,
        leading: IconButton(
          icon: const Icon(Icons.arrow_back, color: Color(0xFF1F2937)),
          onPressed: () => Navigator.pop(context),
        ),
        title: Consumer<QuizProvider>(
          builder: (context, provider, _) {
            return Column(
              children: [
                Text(
                  widget.category.name,
                  style: const TextStyle(
                    fontSize: 16,
                    fontWeight: FontWeight.w500,
                    color: Color(0xFF1F2937),
                  ),
                ),
                if (provider.questions.isNotEmpty)
                  Text(
                    '${provider.currentIndex + 1}/${provider.questions.length}',
                    style: const TextStyle(
                      fontSize: 12,
                      color: Color(0xFF6B7280),
                    ),
                  ),
              ],
            );
          },
        ),
        centerTitle: true,
      ),
      body: Consumer<QuizProvider>(
        builder: (context, provider, _) {
          if (provider.status == QuizStatus.loading) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  CircularProgressIndicator(color: color),
                  const SizedBox(height: 16),
                  const Text(
                    '加载题目中...',
                    style: TextStyle(
                      fontSize: 14,
                      color: Color(0xFF6B7280),
                    ),
                  ),
                ],
              ),
            );
          }

          if (provider.questions.isEmpty) {
            return const Center(
              child: Text(
                '暂无题目',
                style: TextStyle(
                  fontSize: 16,
                  color: Color(0xFF6B7280),
                ),
              ),
            );
          }

          final question = provider.currentQuestion;
          if (question == null) return const SizedBox();

          return Column(
            children: [
              _buildProgress(provider),
              Expanded(
                child: SingleChildScrollView(
                  padding: const EdgeInsets.all(16),
                  child: Column(
                    children: [
                      _QuestionCard(
                        question: question,
                        provider: provider,
                        color: color,
                        onSelect: _handleSelect,
                      ),
                      if (provider.status == QuizStatus.submitted) ...[
                        const SizedBox(height: 16),
                        _ResultCard(question: question, provider: provider),
                        const SizedBox(height: 16),
                        _buildNextButton(provider, color),
                      ],
                    ],
                  ),
                ),
              ),
            ],
          );
        },
      ),
    );
  }

  Widget _buildProgress(QuizProvider provider) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      color: Colors.white,
      child: Row(
        children: List.generate(
          provider.questions.length.clamp(0, 20),
          (index) => Expanded(
            child: Container(
              height: 3,
              margin: const EdgeInsets.symmetric(horizontal: 1),
              decoration: BoxDecoration(
                color: index < provider.currentIndex
                    ? const Color(0xFF6366F1)
                    : index == provider.currentIndex
                        ? const Color(0xFF6366F1).withOpacity(0.5)
                        : const Color(0xFFE5E7EB),
                borderRadius: BorderRadius.circular(2),
              ),
            ),
          ),
        ),
      ),
    );
  }

  void _handleSelect(String optionId) {
    final provider = context.read<QuizProvider>();
    provider.selectAnswer(optionId);
  }

  Widget _buildNextButton(QuizProvider provider, Color color) {
    final isLast = provider.currentIndex >= provider.questions.length - 1;
    
    return Column(
      children: [
        SizedBox(
          width: double.infinity,
          height: 48,
          child: ElevatedButton(
            onPressed: () {
              if (provider.status == QuizStatus.answering) {
                provider.submitAnswer();
              } else {
                provider.nextQuestion();
              }
            },
            style: ElevatedButton.styleFrom(
              backgroundColor: color,
              foregroundColor: Colors.white,
              shape: RoundedRectangleBorder(
                borderRadius: BorderRadius.circular(24),
              ),
            ),
            child: Text(
              provider.status == QuizStatus.answering
                  ? '确认答案'
                  : isLast
                      ? '完成答题'
                      : '下一题',
              style: const TextStyle(fontSize: 16),
            ),
          ),
        ),
        if (isLast && provider.status == QuizStatus.submitted)
          const Padding(
            padding: EdgeInsets.only(top: 8),
            child: Text(
              '已经是最后一题了',
              style: TextStyle(
                fontSize: 12,
                color: Color(0xFF9CA3AF),
              ),
            ),
          ),
      ],
    );
  }

  Color _parseColor(String colorStr) {
    try {
      final hex = colorStr.replaceFirst('#', '');
      return Color(int.parse('FF$hex', radix: 16));
    } catch (e) {
      return const Color(0xFF6366F1);
    }
  }
}

/// 题目卡片
class _QuestionCard extends StatelessWidget {
  final Question question;
  final QuizProvider provider;
  final Color color;
  final Function(String) onSelect;

  const _QuestionCard({
    required this.question,
    required this.provider,
    required this.color,
    required this.onSelect,
  });

  
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(16),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.05),
            blurRadius: 8,
            offset: const Offset(0, 4),
          ),
        ],
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // 标签
          Row(
            children: [
              _buildTag(_getTypeName(question.type), color),
              const SizedBox(width: 8),
              _buildTag(_getDifficultyName(question.difficulty), _getDifficultyColor(question.difficulty)),
              const Spacer(),
              const Text(
                '收藏',
                style: TextStyle(
                  fontSize: 12,
                  color: Color(0xFF9CA3AF),
                ),
              ),
            ],
          ),
          const SizedBox(height: 16),
          // 题目
          Text(
            question.question,
            style: const TextStyle(
              fontSize: 16,
              color: Color(0xFF1F2937),
              height: 1.5,
            ),
          ),
          const SizedBox(height: 24),
          // 选项
          if (question.options != null)
            ...question.options!.map((option) => _OptionItem(
              option: option,
              question: question,
              provider: provider,
              color: color,
              onTap: onSelect,
            )),
        ],
      ),
    );
  }

  Widget _buildTag(String text, Color color) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
      decoration: BoxDecoration(
        color: color.withOpacity(0.2),
        borderRadius: BorderRadius.circular(4),
      ),
      child: Text(
        text,
        style: TextStyle(
          fontSize: 12,
          color: color,
        ),
      ),
    );
  }

  String _getTypeName(QuestionType type) {
    switch (type) {
      case QuestionType.single:
        return '单选题';
      case QuestionType.multiple:
        return '多选题';
      case QuestionType.trueFalse:
        return '判断题';
      case QuestionType.fill:
        return '填空题';
    }
  }

  String _getDifficultyName(Difficulty difficulty) {
    switch (difficulty) {
      case Difficulty.easy:
        return '简单';
      case Difficulty.medium:
        return '中等';
      case Difficulty.hard:
        return '困难';
    }
  }

  Color _getDifficultyColor(Difficulty difficulty) {
    switch (difficulty) {
      case Difficulty.easy:
        return const Color(0xFF10B981);
      case Difficulty.medium:
        return const Color(0xFFF59E0B);
      case Difficulty.hard:
        return const Color(0xFFEF4444);
    }
  }
}

/// 选项组件
class _OptionItem extends StatelessWidget {
  final QuestionOption option;
  final Question question;
  final QuizProvider provider;
  final Color color;
  final Function(String) onTap;

  const _OptionItem({
    required this.option,
    required this.question,
    required this.provider,
    required this.color,
    required this.onTap,
  });

  
  Widget build(BuildContext context) {
    final userAnswer = provider.getUserAnswer(question.id);
    final isSelected = userAnswer is String && userAnswer == option.id ||
        userAnswer is List && userAnswer.contains(option.id);
    final isSubmitted = provider.status == QuizStatus.submitted;
    final isCorrect = question.correctAnswer is String && question.correctAnswer == option.id ||
        question.correctAnswer is List && (question.correctAnswer as List).contains(option.id);

    Color bgColor;
    Color textColor;
    if (isSubmitted) {
      if (isCorrect) {
        bgColor = const Color(0xFF10B981);
        textColor = Colors.white;
      } else if (isSelected && !isCorrect) {
        bgColor = const Color(0xFFEF4444);
        textColor = Colors.white;
      } else {
        bgColor = const Color(0xFFE5E7EB);
        textColor = const Color(0xFF6B7280);
      }
    } else {
      bgColor = isSelected ? color : const Color(0xFFF3F4F6);
      textColor = isSelected ? Colors.white : const Color(0xFF1F2937);
    }

    return GestureDetector(
      onTap: isSubmitted ? null : () => onTap(option.id),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 200),
        margin: const EdgeInsets.only(bottom: 12),
        padding: const EdgeInsets.all(12),
        decoration: BoxDecoration(
          color: bgColor,
          borderRadius: BorderRadius.circular(12),
          border: isSelected && !isSubmitted
              ? Border.all(color: color, width: 2)
              : null,
        ),
        child: Row(
          children: [
            Container(
              width: 32,
              height: 32,
              decoration: BoxDecoration(
                color: isSelected ? Colors.white.withOpacity(0.2) : const Color(0xFFE5E7EB),
                borderRadius: BorderRadius.circular(16),
              ),
              child: Center(
                child: Text(
                  option.label,
                  style: TextStyle(
                    fontSize: 14,
                    fontWeight: FontWeight.w500,
                    color: textColor,
                  ),
                ),
              ),
            ),
            const SizedBox(width: 12),
            Expanded(
              child: Text(
                option.content,
                style: TextStyle(
                  fontSize: 14,
                  color: textColor,
                ),
              ),
            ),
            if (isSubmitted && isCorrect)
              const Icon(Icons.check_circle, color: Colors.white, size: 20),
            if (isSubmitted && isSelected && !isCorrect)
              const Icon(Icons.cancel, color: Colors.white, size: 20),
          ],
        ),
      ),
    );
  }
}

/// 结果卡片
class _ResultCard extends StatelessWidget {
  final Question question;
  final QuizProvider provider;

  const _ResultCard({
    required this.question,
    required this.provider,
  });

  
  Widget build(BuildContext context) {
    final isCorrect = provider.results.isNotEmpty && provider.results.last;

    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: isCorrect
            ? const Color(0xFF10B981).withOpacity(0.1)
            : const Color(0xFFEF4444).withOpacity(0.1),
        borderRadius: BorderRadius.circular(12),
      ),
      child: Column(
        children: [
          Row(
            children: [
              Text(
                isCorrect ? '🎉' : '😢',
                style: const TextStyle(fontSize: 32),
              ),
              const SizedBox(width: 12),
              Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    isCorrect ? '回答正确' : '回答错误',
                    style: TextStyle(
                      fontSize: 18,
                      fontWeight: FontWeight.bold,
                      color: isCorrect
                          ? const Color(0xFF10B981)
                          : const Color(0xFFEF4444),
                    ),
                  ),
                  Text(
                    isCorrect ? '太棒了,继续加油!' : '别灰心,再接再厉!',
                    style: const TextStyle(
                      fontSize: 12,
                      color: Color(0xFF6B7280),
                    ),
                  ),
                ],
              ),
            ],
          ),
          if (!isCorrect) ...[
            const SizedBox(height: 16),
            Container(
              padding: const EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: Colors.white,
                borderRadius: BorderRadius.circular(8),
              ),
              child: Row(
                children: [
                  const Text(
                    '正确答案:',
                    style: TextStyle(
                      fontSize: 14,
                      color: Color(0xFF6B7280),
                    ),
                  ),
                  Text(
                    question.correctAnswer is List
                        ? (question.correctAnswer as List).join('、')
                        : question.correctAnswer.toString(),
                    style: const TextStyle(
                      fontSize: 14,
                      fontWeight: FontWeight.bold,
                      color: Color(0xFF10B981),
                    ),
                  ),
                ],
              ),
            ),
          ],
          const SizedBox(height: 12),
          Container(
            width: double.infinity,
            padding: const EdgeInsets.all(12),
            decoration: BoxDecoration(
              color: Colors.white,
              borderRadius: BorderRadius.circular(8),
            ),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                const Text(
                  '📖 答案解析',
                  style: TextStyle(
                    fontSize: 14,
                    fontWeight: FontWeight.w500,
                    color: Color(0xFF1F2937),
                  ),
                ),
                const SizedBox(height: 8),
                Text(
                  question.explanation,
                  style: const TextStyle(
                    fontSize: 14,
                    color: Color(0xFF6B7280),
                    height: 1.5,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

七、运行截图

以下是应用在鸿蒙设备上的运行效果:

7.1 题库分类列表

在这里插入图片描述

7.2 错题界面

在这里插入图片描述

7.3 模拟考试

在这里插入图片描述

八、代码仓库

本文涉及的完整代码已托管至 AtomGit 平台:

仓库地址:https://atomgit.com/maaath/quiz_app_flutter_ohos

仓库包含以下内容:

  • lib/ - 应用核心代码
  • assets/ - 应用资源文件
  • README.md - 项目说明文档
  • pubspec.yaml - 项目依赖配置

九、总结与展望

通过本文,我们完整实现了一个 Flutter for OpenHarmony 的学习答题应用。项目涵盖了以下核心知识点:

  1. 跨平台架构设计:使用 Flutter 实现一次开发、多平台运行
  2. 状态管理方案:采用 Provider 进行应用状态管理
  3. 网络请求封装:使用 dio 实现统一的网络请求处理
  4. UI 组件化开发:将常用组件抽取为独立 Widget
  5. 动画效果实现:使用 AnimatedContainer 实现选项点击动效

后续扩展方向

  • 添加真实 API 接口对接
  • 实现错题本持久化存储
  • 开发更多的题型支持
  • 添加学习数据分析功能
  • 支持多语言国际化

参考资料

  1. Flutter 官方文档:https://flutter.dev/docs
  2. OpenHarmony 开发者文档:https://developer.harmonyos.com
  3. Provider 状态管理:https://pub.dev/packages/provider
  4. Dio 网络库:https://pub.dev/packages/dio
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐