【HarmonyOS】Flutter实战项目+校园通服务平台全解

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
摘要
本文详细指导开发者从零开始构建"校园通"校园服务平台,涵盖课表查询、校园卡余额、失物招领、NFC模拟等核心功能。文章以Flutter为主UI框架,通过Platform Channel深度集成华为Push Kit、NFC和地图定位等鸿蒙原生能力,实现原子化服务卡片。提供完整项目结构、核心代码示例及上架鸿蒙应用市场的合规要点,帮助开发者快速掌握Flutter+HarmonyOS混合开发技能。
一、项目概述
1.1 应用架构设计
┌─────────────────────────────────────────────────────────────────────┐
│ "校园通"应用整体架构 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Flutter UI层 │ │
│ │ │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │课表页面 │ │校园卡 │ │失物招领 │ │NFC模拟 │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │
│ │ │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │地图服务 │ │消息中心 │ │个人中心 │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 业务逻辑层 (Dart) │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │课表Service│ │卡片Service│ │NFCService │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Platform Channel 桥接层 │ │
│ │ │ │
│ │ MethodChannel: 'com.campus.push' │ │
│ │ MethodChannel: 'com.campus.nfc' │ │
│ │ MethodChannel: 'com.campus.location' │ │
│ │ MethodChannel: 'com.campus.map' │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ HarmonyOS Native Layer (ArkTS) │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │Push Kit │ │NFC Kit │ │Map Kit │ │ │
│ │ │集成 │ │集成 │ │集成 │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
1.2 核心功能模块
| 功能模块 | 说明 | 技术实现 |
|---|---|---|
| 课表查询 | 展示本周课程安排,支持课程提醒 | 原子化服务卡片 + 定时提醒 |
| 校园卡余额 | 查询饭卡余额、消费记录 | Platform Channel + 卡包服务 |
| 失物招领 | 发布和查找失物信息 | 地图定位 + 图片上传 |
| NFC模拟 | 模拟NFC刷卡功能 | NFC Kit + 原子化服务 |
| 消息推送 | 接收校园通知、课程提醒 | Push Kit |
| 地图服务 | 校园导航、失物位置标注 | Map Kit |
1.3 技术栈选型
yaml
# pubspec.yaml
dependencies:
# UI框架
flutter:
sdk: flutter
# 状态管理
flutter_riverpod: ^2.6.1
riverpod_annotation: ^2.6.1
# 网络请求
dio: ^5.7.0
# 本地存储
hive: ^2.2.3
hive_flutter: ^1.1.0
shared_preferences: ^2.3.3
# UI组件
flutter_tab_icon_v2: ^0.0.1
cached_network_image: ^3.4.1
flutter_slidable: ^3.1.1
# 地图与定位
amap_flutter_map: ^3.0.0
amap_flutter_location: ^3.0.0
# NFC
flutter_nfc_kit: ^3.0.0
# 日期处理
intl: ^0.19.0
table_calendar: ^3.1.2
# 鸿蒙适配
flutter_ohos: ^1.0.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
riverpod_generator: ^2.6.1
riverpod_lint: ^2.6.1
build_runner: ^2.4.13
二、项目结构设计
2.1 完整目录结构
campus_pass/
├── lib/
│ ├── main.dart # 应用入口
│ │
│ ├── app.dart # 根组件
│ │
│ ├── core/ # 核心层
│ │ ├── constants/ # 常量定义
│ │ │ ├── api_constants.dart
│ │ │ └── app_constants.dart
│ │ ├── theme/ # 主题配置
│ │ │ ├── app_theme.dart
│ │ │ └── app_colors.dart
│ │ ├── router/ # 路由配置
│ │ │ └── app_router.dart
│ │ ├── utils/ # 工具类
│ │ │ ├── logger.dart
│ │ │ ├── validators.dart
│ │ │ └── date_utils.dart
│ │ └── widgets/ # 通用组件
│ │ ├── app_card.dart # 原子化服务卡片
│ │ ├── app_button.dart
│ │ ├── app_input.dart
│ │ └── loading_shimmer.dart
│ │
│ ├── data/ # 数据层
│ │ ├── models/ # 数据模型
│ │ │ ├── course.dart # 课程模型
│ │ │ ├── campus_card.dart # 校园卡模型
│ │ │ ├── lost_item.dart # 失物模型
│ │ │ └── notification.dart # 通知模型
│ │ │
│ │ ├── repositories/ # 仓储实现
│ │ │ ├── course_repository.dart
│ │ │ ├── card_repository.dart
│ │ │ └── lost_item_repository.dart
│ │ │
│ │ ├── datasources/ # 数据源
│ │ ├── remote/ # 远程数据源
│ │ │ ├── api_service.dart
│ │ │ ├── course_remote_ds.dart
│ │ │ └── card_remote_ds.dart
│ │ │
│ │ └── local/ # 本地数据源
│ │ ├── app_database.dart # Hive数据库
│ │ └── app_preferences.dart # SharedPreferences
│ │
│ ├── domain/ # 领域层
│ │ ├── entities/ # 领域实体
│ │ └── usecases/ # 用例
│ │ ├── get_courses_usecase.dart
│ │ ├── query_balance_usecase.dart
│ │ └── report_lost_item_usecase.dart
│ │
│ └── presentation/ # 表示层
│ ├── providers/ # 状态管理
│ │ ├── auth_provider.dart
│ │ ├── course_provider.dart
│ │ ├── card_provider.dart
│ │ └── notification_provider.dart
│ │
│ ├── pages/ # 页面
│ │ ├── home/ # 首页
│ │ │ └── home_page.dart
│ │ ├── schedule/ # 课表页
│ │ │ └── schedule_page.dart
│ │ ├── campus_card/ # 校园卡页
│ │ │ └── campus_card_page.dart
│ │ ├── lost_found/ # 失物招领页
│ │ │ └── lost_found_page.dart
│ │ ├── nfc_simulator/ # NFC模拟页
│ │ │ └── nfc_simulator_page.dart
│ │ └── map/ # 地图页
│ │ └── campus_map_page.dart
│ │
│ └── widgets/ # 页面专用组件
│ ├── schedule/ # 课表组件
│ │ ├── course_card.dart
│ │ └── week_calendar.dart
│ ├── card/ # 校园卡组件
│ │ ├── balance_card.dart
│ │ └── transaction_item.dart
│ └── lost_found/ # 失物组件
│ ├── lost_item_card.dart
│ └── publish_dialog.dart
│
├── ohos/ # 鸿蒙原生代码
│ └── entry/
│ └── src/main/
│ ├── ets/ # ArkTS代码
│ │ ├── plugins/ # Plugin封装
│ │ │ ├── PushKitPlugin.ts
│ │ │ ├── NFCKitPlugin.ts
│ │ │ └── MapKitPlugin.ts
│ │ ├── services/ # 服务层
│ │ │ ├── PushService.ts
│ │ │ ├── NFCService.ts
│ │ │ └── LocationService.ts
│ │ └── entryability/ # Ability
│ └── resources/ # 资源文件
│ ├── base/
│ │ └── element/
│ │ ├── string.json # 字符串资源
│ └── rawfile/ # 原始文件
│
├── android/ # Android平台代码
├── ios/ # iOS平台代码
├── web/ # Web平台代码
├── windows/ # Windows平台代码
├── test/ # 测试代码
├── pubspec.yaml # 依赖配置
├── build-profile.json5 # 构建配置
└── README.md # 项目说明
三、Platform Channel桥接实现
3.1 推送服务集成
Dart端:PushService
dart
// lib/data/services/push_service.dart
import 'package:flutter/services.dart';
/// 推送服务
class PushService {
static const MethodChannel _channel = MethodChannel('com.campus.push');
static const EventChannel _eventChannel = EventChannel('com.campus.push_events');
Stream<Map<String, dynamic>>? _notificationStream;
/// 初始化推送服务
Future<bool> initialize({
required String appId,
}) async {
try {
final result = await _channel.invokeMethod<bool>('initialize', {
'appId': appId,
});
return result ?? false;
} on PlatformException catch (e) {
throw PushException('初始化失败: ${e.message}');
}
}
/// 请求推送权限
Future<bool> requestPermission() async {
try {
final result = await _channel.invokeMethod<bool>('requestPermission');
return result ?? false;
} on PlatformException catch (e) {
throw PushException('请求权限失败: ${e.message}');
}
}
/// 获取Token
Future<String?> getToken() async {
try {
final result = await _channel.invokeMethod<String>('getToken');
return result;
} on PlatformException catch (e) {
throw PushException('获取Token失败: ${e.message}');
}
}
/// 订阅主题
Future<bool> subscribeToTopic(String topic) async {
try {
final result = await _channel.invokeMethod<bool>('subscribeToTopic', {
'topic': topic,
});
return result ?? false;
} on PlatformException catch (e) {
throw PushException('订阅主题失败: ${e.message}');
}
}
/// 取消订阅
Future<bool> unsubscribeFromTopic(String topic) async {
try {
final result = await _channel.invokeMethod<bool>('unsubscribeFromTopic', {
'topic': topic,
});
return result ?? false;
} on PlatformException catch (e) {
throw PushException('取消订阅失败: ${e.message}');
}
}
/// 监听推送消息
Stream<Map<String, dynamic>> get notificationStream {
_notificationStream ??= _eventChannel.receiveBroadcastStream().map(
(dynamic event) {
final Map<String, dynamic> data = event as Map<String, dynamic>;
return {
'title': data['title'] as String? ?? '',
'content': data['content'] as String? ?? '',
'extra': data['extra'] as Map<String, dynamic>? ?? {},
};
},
);
return _notificationStream!;
}
/// 本地通知
Future<bool> showLocalNotification({
required String title,
required String content,
Map<String, dynamic>? payload,
}) async {
try {
final result = await _channel.invokeMethod<bool>('showLocalNotification', {
'title': title,
'content': content,
'payload': payload,
});
return result ?? false;
} on PlatformException catch (e) {
throw PushException('显示通知失败: ${e.message}');
}
}
/// 设置通知渠道
Future<bool> createNotificationChannel({
required String channelId,
required String channelName,
String description = '',
}) async {
try {
final result = await _channel.invokeMethod<bool>('createNotificationChannel', {
'channelId': channelId,
'channelName': channelName,
'description': description,
});
return result ?? false;
} on PlatformException catch (e) {
throw PushException('创建通知渠道失败: ${e.message}');
}
}
}
/// 推送异常
class PushException implements Exception {
final String message;
PushException(this.message);
@override
String toString() => message;
}
鸿蒙原生端:PushKitPlugin
typescript
// ohos/entry/src/main/ets/plugins/PushKitPlugin.ts
import pushService from '@ohos.pushKit';
import { MethodCall, MethodCallHandler, MethodResult, MethodChannel } from '@ohos/flutter_ohos';
/**
* 华为Push Kit Plugin
* 实现Flutter与鸿蒙推送服务的桥接
*/
export class PushKitPlugin implements MethodCallHandler {
private static readonly CHANNEL_NAME = 'com.campus.push';
private pushService: pushService.PushService;
private notificationId = 0;
/**
* 注册Plugin到Flutter Engine
*/
static registerWith(flutterEngine: any): void {
const channel = new MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
PushKitPlugin.CHANNEL_NAME
);
const plugin = new PushKitPlugin();
channel.setMethodCallHandler(plugin);
}
constructor() {
this.pushService = new pushService.PushService();
}
/**
* 处理来自Dart的方法调用
*/
async onMethodCall(call: MethodCall, result: MethodResult): Promise<void> {
switch (call.method) {
case 'initialize':
await this.handleInitialize(call, result);
break;
case 'requestPermission':
await this.handleRequestPermission(call, result);
break;
case 'getToken':
await this.handleGetToken(call, result);
break;
case 'subscribeToTopic':
await this.handleSubscribeToTopic(call, result);
break;
case 'unsubscribeFromTopic':
await this.handleUnsubscribeFromTopic(call, result);
break;
case 'showLocalNotification':
await this.handleShowLocalNotification(call, result);
break;
case 'createNotificationChannel':
await this.handleCreateNotificationChannel(call, result);
break;
default:
result.notImplemented();
}
}
/**
* 初始化Push服务
*/
private async handleInitialize(call: MethodCall, result: MethodResult): Promise<void> {
try {
const appId: string = call.args()['appId'];
// 配置Push服务
const pushOption: pushService.PushOption = {
appId: appId,
// 启用调试模式(生产环境设为false)
debug: false,
// 设置通知渠道
notificationChannelOption: {
// 课程提醒渠道
courseReminder: {
channelName: '课程提醒',
channelDescription: '用于推送课程开始提醒',
channelLevel: pushService.NotificationLevel.LEVEL_HIGH,
lockscreenVisibility: pushService.LockscreenVisibility.PUBLIC,
sound: 'course_reminder.mp3',
light: true,
vibration: true,
},
// 校园通知渠道
campusNotification: {
channelName: '校园通知',
channelDescription: '用于推送校园重要通知',
channelLevel: pushService.NotificationLevel.LEVEL_DEFAULT,
lockscreenVisibility: pushService.LockscreenVisibility.PUBLIC,
sound: 'default',
vibration: true,
},
},
};
await this.pushService.init(pushOption);
// 注册通知监听器
this.pushService.on('notification', (data: pushService.PushData) => {
this._sendNotificationToDart(data);
});
// 注册Token监听器
this.pushService.on('token', (data: pushService.PushToken) => {
this._sendTokenToDart(data);
});
result.success(true);
} catch (error) {
result.error('INIT_FAILED', `初始化Push服务失败: ${error.message}`, null);
}
}
/**
* 请求推送权限
*/
private async handleRequestPermission(call: MethodCall, result: MethodResult): Promise<void> {
try {
const permission = await this.pushService.requestPermission(
pushService.NotificationPermission.NOTIFICATION,
{
reason: '需要推送权限以接收课程提醒和校园通知',
}
);
result.success(permission === pushService.AuthorizedResult.ALLOWED);
} catch (error) {
result.error('PERMISSION_DENIED', `请求权限失败: ${error.message}`, null);
}
}
/**
* 获取推送Token
*/
private async handleGetToken(call: MethodCall, result: MethodResult): Promise<void> {
try {
const token = await this.pushService.getToken();
result.success(token);
} catch (error) {
result.error('GET_TOKEN_FAILED', `获取Token失败: ${error.message}`, null);
}
}
/**
* 订阅主题
*/
private async handleSubscribeToTopic(call: MethodCall, result: MethodResult): Promise<void> {
try {
const topic: string = call.args()['topic'];
await this.pushService.subscribe(topic);
result.success(true);
} catch (error) {
result.error('SUBSCRIBE_FAILED', `订阅主题失败: ${error.message}`, null);
}
}
/**
* 取消订阅主题
*/
private async handleUnsubscribeFromTopic(call: MethodCall, result: MethodResult): Promise<void> {
try {
const topic: string = call.args()['topic'];
await this.pushService.unsubscribe(topic);
result.success(true);
} catch (error) {
result.error('UNSUBSCRIBE_FAILED', `取消订阅失败: ${error.message}`, null);
}
}
/**
* 显示本地通知
*/
private async handleShowLocalNotification(call: MethodCall, result: MethodResult): Promise<void> {
try {
const title: string = call.args()['title'];
const content: string = call.args()['content'];
const payload: ESObject = call.args()['payload'] || {};
const notificationRequest: pushService.NotificationRequest = {
id: this.notificationId++,
content: {
notificationContentType: pushService.NotificationContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
notificationCategory: pushService.NotificationCategory.SOCIAL_COMMUNICATION,
title: title,
text: content,
},
notificationSlotType: pushService.NotificationSlotType.SOCIAL_COMMUNICATION,
// 自定义点击动作
actionButtons: [
{
title: '查看详情',
intent: {
packageName: 'com.campus.pass',
abilityName: 'EntryAbility',
},
},
],
};
await this.pushService.publish(notificationRequest);
result.success(true);
} catch (error) {
result.error('SHOW_NOTIFICATION_FAILED', `显示通知失败: ${error.message}`, null);
}
}
/**
* 创建通知渠道
*/
private async handleCreateNotificationChannel(call: MethodCall, result: MethodResult): Promise<void> {
try {
const channelId: string = call.args()['channelId'];
const channelName: string = call.args()['channelName'];
const description: string = call.args()['description'];
// 鸿蒙通知渠道已在init中配置
result.success(true);
} catch (error) {
result.error('CREATE_CHANNEL_FAILED', `创建通知渠道失败: ${error.message}`, null);
}
}
/**
* 发送通知到Dart层
*/
private _sendNotificationToDart(data: pushService.PushData): void {
const notificationData = {
title: data.notification?.title || '',
content: data.notification?.content || '',
extra: data.payload || {},
};
// 通过EventChannel发送
// eventChannel.emit('com.campus.push_events/notification', notificationData);
}
/**
* 发送Token到Dart层
*/
private _sendTokenToDart(data: pushService.PushToken): void {
const tokenData = {
token: data.token || '',
appId: data.appId || '',
};
// 保存Token到本地或发送到服务器
console.log('Push Token:', tokenData);
}
}
3.2 NFC服务集成
Dart端:NFCService
dart
// lib/data/services/nfc_service.dart
import 'package:flutter/services.dart';
/// NFC数据类型
enum NFCDataType {
campusCard,
doorAccess,
library,
}
/// NFC读取结果
class NFCReadResult {
final String data;
final NFCDataType dataType;
final DateTime timestamp;
NFCReadResult({
required this.data,
required this.dataType,
required this.timestamp,
});
/// NFC服务
class NFCService {
static const MethodChannel _channel = MethodChannel('com.campus.nfc');
/// 初始化NFC
Future<bool> initialize() async {
try {
final result = await _channel.invokeMethod<bool>('initialize');
return result ?? false;
} on PlatformException catch (e) {
throw NFCException('初始化NFC失败: ${e.message}');
}
}
/// 检查NFC是否可用
Future<bool> isAvailable() async {
try {
final result = await _channel.invokeMethod<bool>('isAvailable');
return result ?? false;
} on PlatformException catch (e) {
throw NFCException('检查NFC可用性失败: ${e.message}');
}
}
/// 开始读取NFC标签
Future<NFCReadResult?> startReading() async {
try {
final result = await _channel.invokeMethod<Map<String, dynamic>>('startReading');
if (result == null) return null;
return NFCReadResult(
data: result['data'] as String,
dataType: _stringToNFCType(result['type'] as String? ?? ''),
timestamp: DateTime.now(),
);
} on PlatformException catch (e) {
throw NFCException('读取NFC失败: ${e.message}');
}
}
/// 停止读取NFC
Future<void> stopReading() async {
try {
await _channel.invokeMethod('stopReading');
} on PlatformException catch (e) {
throw NFCException('停止读取失败: ${e.message}');
}
}
/// 写入NFC数据
Future<bool> writeData({
required String data,
required NFCDataType dataType,
}) async {
try {
final result = await _channel.invokeMethod<bool>('writeData', {
'data': data,
'type': _nfcTypeToString(dataType),
});
return result ?? false;
} on PlatformException catch (e) {
throw NFCException('写入NFC失败: ${e.message}');
}
}
/// 模拟NFC刷卡(用于测试)
Future<NFCReadResult> simulateCardSwipe({
required String cardId,
required double balance,
}) async {
// 模拟NFC读取延迟
await Future.delayed(const Duration(milliseconds: 500));
return NFCReadResult(
data: cardId,
dataType: NFCDataType.campusCard,
timestamp: DateTime.now(),
);
}
String _nfcTypeToString(NFCDataType type) {
switch (type) {
case NFCDataType.campusCard:
return 'campus_card';
case NFCDataType.doorAccess:
return 'door_access';
case NFCDataType.library:
return 'library';
}
}
NFCDataType _stringToNFCType(String type) {
switch (type) {
case 'campus_card':
return NFCDataType.campusCard;
case 'door_access':
return NFCDataType.doorAccess;
case 'library':
return NFCDataType.library;
default:
return NFCDataType.campusCard;
}
}
}
/// NFC异常
class NFCException implements Exception {
final String message;
NFCException(this.message);
@override
String toString() => message;
}
鸿蒙原生端:NFCKitPlugin
typescript
// ohos/entry/src/main/ets/plugins/NFCKitPlugin.ts
import { MethodCall, MethodCallHandler, MethodResult, MethodChannel } from '@ohos/flutter_ohos';
import nfcController from '@ohos.nfc.controller';
import { NfcTech } from '@ohos.nfc.tag';
/**
* NFC Kit Plugin
* 实现Flutter与鸿蒙NFC服务的桥接
*/
export class NFCKitPlugin implements MethodCallHandler {
private static readonly CHANNEL_NAME = 'com.campus.nfc';
private nfcController: nfcController.NfcController;
private isReading = false;
/**
* 注册Plugin到Flutter Engine
*/
static registerWith(flutterEngine: any): void {
const channel = new MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
NFCKitPlugin.CHANNEL_NAME
);
const plugin = new NFCKitPlugin();
channel.setMethodCallHandler(plugin);
}
constructor() {
// 创建NFC控制器
this.nfcController = new nfcController.NfcController();
}
/**
* 处理来自Dart的方法调用
*/
async onMethodCall(call: MethodCall, result: MethodResult): Promise<void> {
switch (call.method) {
case 'initialize':
await this.handleInitialize(call, result);
break;
case 'isAvailable':
await this.handleIsAvailable(call, result);
break;
case 'startReading':
await this.handleStartReading(call, result);
break;
case 'stopReading':
await this.handleStopReading(call, result);
break;
case 'writeData':
await this.handleWriteData(call, result);
break;
default:
result.notImplemented();
}
}
/**
* 初始化NFC控制器
*/
private async handleInitialize(call: MethodCall, result: MethodResult): Promise<void> {
try {
// 配置NFC技术类型
const techTypes: Array<NfcTech> = [
NfcTech.ISO_DEP,
NfcTech.ISO_IClassA,
NfcTech.ISO_IClassB,
NfcTech.MIFARE_CLASSIC,
];
// 初始化NFC控制器
await this.nfcController.init(techTypes);
result.success(true);
} catch (error) {
result.error('INIT_FAILED', `初始化NFC失败: ${error.message}`, null);
}
}
/**
* 检查NFC是否可用
*/
private async handleIsAvailable(call: MethodCall, result: MethodResult): Promise<void> {
try {
const available = await this.nfcController.isNfcAvailable();
result.success(available);
} catch (error) {
result.error('CHECK_FAILED', `检查NFC失败: ${error.message}`, null);
}
}
/**
* 开始读取NFC标签
*/
private async handleStartReading(call: MethodCall, result: MethodResult): Promise<void> {
if (this.isReading) {
result.success(null);
return;
}
try {
this.isReading = true;
// 注册NFC标签监听器
this.nfcController.on('discovered', (tagInfo) => {
this._processNfcTag(tagInfo, result);
});
// 开始轮询NFC标签
const flags: Array<nfcController.DiscoveredFilter> = [
nfcController.DiscoveredFilter.NDEF_TECH,
nfcController.DiscoveredFilter.NDEF_FORMAT,
nfcController.DiscoveredFilter.NDEF_FORUM_NDEF,
];
const discoveredFilter: nfcController.DiscoveredFilter = {
tech: [NfcTech.ISO_DEP],
};
await this.nfcController.on(discoveredFilter);
} catch (error) {
this.isReading = false;
result.error('READ_FAILED', `开始读取失败: ${error.message}`, null);
}
}
/**
* 停止读取NFC标签
*/
private async handleStopReading(call: MethodCall, result: MethodResult): Promise<void> {
try {
this.isReading = false;
await this.nfcController.off();
result.success(true);
} catch (error) {
result.error('STOP_FAILED', `停止读取失败: ${error.message}`, null);
}
}
/**
* 处理NFC标签数据
*/
private async _processNfcTag(
tagInfo: nfcController.TagInfo,
result: MethodResult
): Promise<void> {
try {
const tag = tagInfo.tag;
// 读取NDEF数据
const ndefMessage = await tag.getNdefMessage();
if (ndefMessage == null || ndefMessage.records.length === 0) {
result.success({
data: '',
type: 'unknown',
});
return;
}
// 解析NDEF记录
const record = ndefMessage.records[0];
const payload = record.payload;
// 根据payload类型解析数据
let nfcData: ESObject = {
data: '',
type: 'campus_card',
};
if (payload instanceof ArrayBuffer) {
const textDecoder = new TextDecoder('utf-8');
const text = textDecoder.decode(payload);
nfcData.data = text;
// 判断卡片类型
if (text.startsWith('CARD_')) {
nfcData.type = 'campus_card';
} else if (text.startsWith('DOOR_')) {
nfcData.type = 'door_access';
} else if (text.startsWith('LIB_')) {
nfcData.type = 'library';
}
}
result.success(nfcData);
this.isReading = false;
} catch (error) {
this.isReading = false;
result.error('PROCESS_FAILED', `处理NFC数据失败: ${error.message}`, null);
}
}
/**
* 写入NFC数据
*/
private async handleWriteData(call: MethodCall, result: MethodResult): Promise<void> {
try {
const data: string = call.args()['data'];
const type: string = call.args()['type'];
// 创建NDEF记录
const textEncoder = new TextEncoder();
const payload = textEncoder.encodeInto(data);
const ndefRecord: nfcController.NdefRecord = {
id: new Uint8Array([]),
tnf: nfcController.TNF_WELL_KNOWN,
type: new Uint8Array([0x54]), // Text类型
payload: payload,
};
const ndefMessage: nfcController.NdefMessage = {
records: [ndefRecord],
};
// 写入NFC标签(需要标签靠近)
result.success({
message: '请将NFC标签靠近设备',
ndefMessage: ndefMessage,
});
} catch (error) {
result.error('WRITE_FAILED', `写入NFC数据失败: ${error.message}`, null);
}
}
}
3.3 地图定位服务集成
Dart端:LocationService
dart
// lib/data/services/location_service.dart
import 'package:flutter/material.dart';
import 'package:amap_flutter_location/amap_flutter_location.dart';
/// 位置信息模型
class LocationInfo {
final double latitude;
final double longitude;
final String address;
final DateTime timestamp;
LocationInfo({
required this.latitude,
required this.longitude,
required this.address,
required this.timestamp,
});
/// 定位服务
class LocationService {
static final AmapFlutterLocation _locationPlugin = AmapFlutterLocation();
Stream<LatLng>? _locationStream;
/// 初始化定位服务
Future<bool> initialize({
required String iosKey,
required String androidKey,
}) async {
try {
// 配置高德地图Key
AmapFlutterLocation.setApiKey(iosKey: iosKey, androidKey: androidKey);
// 设置隐私协议
AmapFlutterLocation.updatePrivacyShow(false);
AmapFlutterLocation.updatePrivacyAgree(true);
// 设置定位参数
await AmapFlutterLocation.setLocationOption(
AmapLocationOption(
// 定位模式
locationMode: AmapLocationMode.hightAccuracy,
// 定位间隔
locationInterval: 2000,
// 返回地址信息
needAddress: true,
// 一次定位
onceLocation: false,
// 位置过滤
gpsFirst: true,
// 超时时间
httpTimeOut: 20000,
),
);
return true;
} catch (e) {
debugPrint('初始化定位服务失败: $e');
return false;
}
}
/// 请求定位权限
Future<bool> requestPermission() async {
try {
final result = await AmapFlutterLocation.requestPermission();
return result?.contains(Permission.permissionAlways) ?? false;
} catch (e) {
debugPrint('请求定位权限失败: $e');
return false;
}
}
/// 获取当前位置
Future<LocationInfo?> getCurrentLocation() async {
try {
final result = await AmapFlutterLocation.getLocation();
if (result == null) return null;
return LocationInfo(
latitude: result.latitude,
longitude: result.longitude,
address: result.formattedAddress ?? '未知位置',
timestamp: DateTime.now(),
);
} catch (e) {
debugPrint('获取当前位置失败: $e');
return null;
}
}
/// 开始连续定位
Stream<LocationInfo> startLocationStream() {
_locationStream ??= AmapFlutterLocation.onLocationChanged().map((location) {
return LocationInfo(
latitude: location.latitude,
longitude: location.longitude,
address: location.formattedAddress ?? '未知位置',
timestamp: DateTime.now(),
);
});
return _locationStream!;
}
/// 停止定位
Future<void> stopLocation() async {
try {
await AmapFlutterLocation.stopLocate();
} catch (e) {
debugPrint('停止定位失败: $e');
}
}
/// 计算两点距离(单位:米)
double calculateDistance({
required double startLat,
required double startLng,
required double endLat,
required double endLng,
}) {
const double earthRadius = 6371000; // 地球半径,单位:米
final double dLat = _degreesToRadians(endLat - startLat);
final double dLng = _degreesToRadians(endLng - startLng);
final double a = (dLat / 2).abs() *
(dLat / 2).abs() +
(dLng / 2).abs() *
(dLng / 2).abs() *
(dLng / 2).abs() *
startLat.cos() *
endLat.cos();
final double c = 2 * (a.sqrt() * (1 - a).sqrt()).asin();
return earthRadius * c;
}
double _degreesToRadians(double degrees) {
return degrees * (3.14159265359 / 180);
}
}
四、原子化服务卡片实现
4.1 原子化服务卡片基础组件
dart
// lib/core/widgets/app_card.dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
/// 原子化服务卡片配置
class ServiceCardConfig {
final String id;
final String title;
final String description;
final IconData icon;
final Color iconColor;
final String? route;
final VoidCallback? onTap;
final bool isPinned;
final int badgeCount;
const ServiceCardConfig({
required this.id,
required this.title,
required this.description,
required this.icon,
required this.iconColor,
this.route,
this.onTap,
this.isPinned = false,
this.badgeCount = 0,
});
}
/// 原子化服务卡片Widget
class ServiceCard extends StatelessWidget {
final ServiceCardConfig config;
final VoidCallback? onPinToggle;
final VoidCallback? onReorder;
const ServiceCard({
super.key,
required this.config,
this.onPinToggle,
this.onReorder,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: config.onTap ?? () => _handleTap(context),
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 头部:图标、标题和固定按钮
Row(
children: [
// 图标
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: config.iconColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
config.icon,
color: config.iconColor,
size: 24,
),
),
const SizedBox(width: 12),
// 标题和徽章
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
config.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (config.badgeCount > 0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${config.badgeCount}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
Text(
config.description,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
// 固定按钮
if (onPinToggle != null)
IconButton(
icon: Icon(
config.isPinned
? Icons.push_pin
: Icons.push_pin_outlined,
color: config.isPinned
? Colors.blue
: Colors.grey[400],
),
onPressed: onPinToggle,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 40,
minHeight: 40,
),
),
],
),
const SizedBox(height: 12),
// 快捷操作区(可扩展)
if (config.id == 'campus_card')
_buildCampusCardActions(context)
else if (config.id == 'schedule')
_buildScheduleActions(context)
else if (config.id == 'lost_found')
_buildLostFoundActions(context),
],
),
),
),
),
);
}
Widget _buildCampusCardActions(BuildContext context) {
return Row(
children: [
_buildActionButton(
icon: Icons.account_balance_wallet_outlined,
label: '余额',
color: Colors.blue,
onTap: () => _showBalance(context),
),
const SizedBox(width: 12),
_buildActionButton(
icon: Icons.receipt_long_outlined,
label: '充值',
color: Colors.green,
onTap: () => _showRecharge(context),
),
const Spacer(),
_buildActionButton(
icon: Icons.qr_code_scanner,
label: '扫码',
color: Colors.orange,
onTap: () => _showQRScan(context),
),
],
);
}
Widget _buildScheduleActions(BuildContext context) {
final now = DateTime.now();
final hour = now.hour;
String nextCourse = '';
if (hour < 8) {
nextCourse = '今日暂无课程';
} else if (hour < 10) {
nextCourse = '下一节:10:00 高等数学';
} else if (hour < 12) {
nextCourse = '下一节:14:00 英语';
} else {
nextCourse = '今日课程已结束';
}
return Row(
children: [
Icon(
Icons.schedule_outlined,
color: Colors.blue,
size: 16,
),
const SizedBox(width: 6),
Expanded(
child: Text(
nextCourse,
style: TextStyle(
fontSize: 13,
color: Colors.grey[700],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
Widget _buildLostFoundActions(BuildContext context) {
return Row(
children: [
_buildActionButton(
icon: Icons.search_outlined,
label: '查找',
color: Colors.blue,
onTap: () => _showSearch(context),
),
const SizedBox(width: 12),
_buildActionButton(
icon: Icons.add_circle_outline,
label: '发布',
color: Colors.green,
onTap: () => _showPublish(context),
),
],
);
}
Widget _buildActionButton({
required IconData icon,
required String label,
required Color color,
VoidCallback? onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: color,
size: 16,
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 12,
color: color,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
void _handleTap(BuildContext context) {
if (config.onTap != null) {
config.onTap!();
} else if (config.route != null) {
// 导航到对应页面
Navigator.pushNamed(context, config.route!);
}
}
void _showBalance(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (context) => _BalanceBottomSheet(),
);
}
void _showRecharge(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('充值功能开发中...')),
);
}
void _showQRScan(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('扫码功能开发中...')),
);
}
void _showSearch(BuildContext context) {
Navigator.pushNamed(context, '/lost_found/search');
}
void _showPublish(BuildContext context) {
Navigator.pushNamed(context, '/lost_found/publish');
}
}
/// 余额底部弹窗
class _BalanceBottomSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(24),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Container(
width: 4,
height: 40,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'校园卡余额',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
'卡号: **** **** **** 8888',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
],
),
const Divider(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'当前余额',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
'¥ 256.80',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
],
),
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: Icon(
Icons.qr_code_2,
size: 40,
color: Colors.grey,
),
),
),
],
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () {
Navigator.pop(context);
_showTransactionHistory(context);
},
icon: const Icon(Icons.receipt_long),
label: '消费记录',
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.refresh),
label: '刷新余额',
style: OutlinedButton.styleFrom(
foregroundColor: Colors.blue,
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
],
),
],
),
);
}
void _showTransactionHistory(BuildContext context) {
Navigator.pushNamed(context, '/campus_card/transactions');
}
}
4.2 卡片管理Provider
dart
// lib/presentation/providers/service_card_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../core/widgets/app_card.dart';
part 'service_card_provider.g.dart';
/// 默认服务卡片配置
final _defaultServiceCards = [
ServiceCardConfig(
id: 'schedule',
title: '课程表',
description: '查看本周课程安排,设置上课提醒',
icon: Icons.calendar_month_outlined,
iconColor: Color(0xFF1890FF),
route: '/schedule',
isPinned: true,
),
ServiceCardConfig(
id: 'campus_card',
title: '校园卡',
description: '查询余额、消费记录,支持NFC充值',
icon: Icons.account_balance_wallet_outlined,
iconColor: Color(0xFF52C41A),
route: '/campus_card',
isPinned: true,
),
ServiceCardConfig(
id: 'library',
title: '图书馆',
description: '图书借阅、座位预约、NFC门禁',
icon: Icons.local_library_outlined,
iconColor: Color(0xFF722ED1),
route: '/library',
),
ServiceCardConfig(
id: 'lost_found',
title: '失物招领',
description: '发布和查找失物,地图定位标记',
icon: Icons.search_outlined,
iconColor: Color(0xFFFFA940),
route: '/lost_found',
),
ServiceCardConfig(
id: 'nfc_simulator',
title: 'NFC模拟',
description: '模拟NFC刷卡,测试门禁功能',
icon: Icons.nfc_outlined,
iconColor: Color(0xFF13C2C2),
route: '/nfc_simulator',
),
ServiceCardConfig(
id: 'map',
title: '校园地图',
description: '校园导航、失物位置标注',
icon: Icons.map_outlined,
iconColor: Color(0xFF2ED573),
route: '/map',
),
ServiceCardConfig(
id: 'announcements',
title: '通知公告',
description: '校园重要通知、活动信息推送',
icon: Icons.notifications_outlined,
iconColor: Color(0xFFFA541C),
badgeCount: 3,
isPinned: true,
),
ServiceCardConfig(
id: 'feedback',
title: '意见反馈',
description: '提交问题和建议,获取响应',
icon: Icons.feedback_outlined,
iconColor: Color(0xFF595959),
),
];
/// 服务卡片状态
@riverpod
class ServiceCardNotifier extends _$ServiceCardNotifier {
@override
List<ServiceCardConfig> build() {
return List.from(_defaultServiceCards);
}
/// 切换固定状态
void togglePin(String id) {
state = [
for (final card in state)
if (card.id == id)
card.copyWith(isPinned: !card.isPinned)
else
card,
];
}
/// 更新徽章数量
void updateBadgeCount(String id, int count) {
state = [
for (final card in state)
if (card.id == id)
card.copyWith(badgeCount: count)
else
card,
];
}
/// 获取固定的卡片
List<ServiceCardConfig> get pinnedCards {
return state.where((card) => card.isPinned).toList()
..sort((a, b) => a.title.compareTo(b.title));
}
/// 获取未固定的卡片
List<ServiceCardConfig> get unpinnedCards {
return state.where((card) => !card.isPinned).toList()
..sort((a, b) => a.title.compareTo(b.title));
}
}
五、核心页面实现
5.1 首页(服务卡片聚合)
dart
// lib/presentation/pages/home/home_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/widgets/app_card.dart';
import '../../../presentation/providers/service_card_provider.dart';
import '../../widgets/common/app_loading.dart';
class HomePage extends ConsumerStatefulWidget {
const HomePage({super.key});
@override
ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends ConsumerState<HomePage> {
@override
Widget build(BuildContext context) {
final cards = ref.watch(serviceCardNotifierProvider);
final pinnedCards = ref.read(serviceCardNotifierProvider.notifier).pinnedCards;
final unpinnedCards = ref.read(serviceCardNotifierProvider.notifier).unpinnedCards;
return Scaffold(
body: CustomScrollView(
slivers: [
// AppBar
SliverAppBar(
expandedHeight: 120,
floating: true,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: const Text('校园通'),
background: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF1890FF),
Color(0xFF52C41A),
],
),
),
),
),
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () => _showSettings(context),
),
IconButton(
icon: const Icon(Icons.notifications_outlined),
onPressed: () => _showNotifications(context),
),
],
),
// 固定的卡片
if (pinnedCards.isNotEmpty)
SliverToBoxAdapter(
child: _buildPinnedCardsSection(pinnedCards),
),
// 未固定的卡片
if (unpinnedCards.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.1,
),
delegate: SliverChildBuilderDelegate(
childCount: unpinnedCards.length,
builder: (context, index) {
final card = unpinnedCards[index];
return ServiceCard(
config: card,
onPinToggle: () {
ref.read(serviceCardNotifierProvider.notifier).togglePin(card.id);
},
);
},
),
),
),
// 底部间距
const SliverToBoxAdapter(
child: SizedBox(height: 100),
),
],
),
);
}
Widget _buildPinnedCardsSection(List<ServiceCardConfig> pinnedCards) {
return Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题
Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 12),
child: Row(
children: [
Icon(
Icons.push_pin,
size: 16,
color: Colors.blue,
),
const SizedBox(width: 6),
Text(
'常用服务',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.grey[700],
),
),
],
),
),
// 卡片列表
SizedBox(
height: 160,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: pinnedCards.length,
itemBuilder: (context, index) {
final card = pinnedCards[index];
return Container(
width: 280,
margin: const EdgeInsets.only(right: 12),
child: ServiceCard(config: card),
);
},
),
),
],
),
);
}
void _showSettings(BuildContext context) {
Navigator.pushNamed(context, '/settings');
}
void _showNotifications(BuildContext context) {
Navigator.pushNamed(context, '/notifications');
}
}
5.2 课表查询页面
dart
// lib/presentation/pages/schedule/schedule_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:table_calendar/table_calendar.dart';
import '../../../data/models/course.dart';
import '../../../presentation/providers/course_provider.dart';
class SchedulePage extends ConsumerStatefulWidget {
const SchedulePage({super.key});
@override
ConsumerState<SchedulePage> createState() => _SchedulePageState();
}
class _SchedulePageState extends ConsumerState<SchedulePage> {
@override
Widget build(BuildContext context) {
final coursesAsync = ref.watch(courseListProvider);
final selectedDay = ref.watch(selectedDayProvider);
return Scaffold(
appBar: AppBar(
title: const Text('课程表'),
actions: [
IconButton(
icon: const Icon(Icons.today_outlined),
onPressed: () => _goToToday(),
),
IconButton(
icon: const Icon(Icons.notifications_outlined),
onPressed: () => _showReminderSettings(context),
),
],
),
body: Column(
children: [
// 周历选择器
_buildWeekCalendar(),
const Divider(height: 1),
// 课程列表
Expanded(
child: coursesAsync.when(
data: (courses) => _buildCourseList(courses, selectedDay),
loading: () => const AppLoading(),
error: (error, stack) => _buildErrorView(error),
),
),
],
),
);
}
Widget _buildWeekCalendar() {
final selectedDay = ref.watch(selectedDayProvider);
return TableCalendar(
firstDay: DateTime.monday,
locale: 'zh_CN',
focusedDay: selectedDay,
onDaySelected: (day, focusedDay) {
ref.read(selectedDayProvider.notifier).state = day;
},
calendarStyle: CalendarStyle(
outsideDaysVisible: false,
weekendTextStyle: const TextStyle(color: Colors.red),
selectedDecoration: BoxDecoration(
color: Colors.blue[400],
shape: BoxShape.circle,
),
todayDecoration: BoxDecoration(
color: Colors.blue[100],
shape: BoxShape.circle,
),
),
headerStyle: const HeaderStyle(
formatButtonVisible: false,
titleCentered: true,
),
);
}
Widget _buildCourseList(List<Course> courses, DateTime selectedDay) {
// 筛选选中日期的课程
final selectedCourses = courses.where((course) {
return course.date.year == selectedDay.year &&
course.date.month == selectedDay.month &&
course.date.day == selectedDay.day;
}).toList();
// 按时间排序
selectedCourses.sort((a, b) => a.startTime.compareTo(b.startTime));
if (selectedCourses.isEmpty) {
return _buildEmptyView(selectedDay);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: selectedCourses.length,
itemBuilder: (context, index) {
final course = selectedCourses[index];
return _buildCourseCard(course);
},
);
}
Widget _buildCourseCard(Course course) {
final startTime = DateFormat('HH:mm').format(course.startTime);
final endTime = DateFormat('HH:mm').format(course.endTime);
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
// 时间列
Container(
width: 60,
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: _getCourseColor(course.name).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Text(
startTime,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _getCourseColor(course.name),
),
),
const SizedBox(height: 4),
Text(
endTime,
style: TextStyle(
fontSize: 12,
color: _getCourseColor(course.name),
),
),
],
),
),
const SizedBox(width: 16),
// 课程信息列
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
course.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
'${course.teacher} · ${course.location}',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
if (course.note != null && course.note!.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.orange[50],
borderRadius: BorderRadius.circular(8),
),
child: Text(
course.note!,
style: TextStyle(
fontSize: 12,
color: Colors.orange[900],
),
),
),
],
],
),
),
// 操作按钮
IconButton(
icon: const Icon(Icons.notifications_outlined),
onPressed: () => _setCourseReminder(course),
),
],
),
);
}
Widget _buildEmptyView(DateTime selectedDay) {
final weekday = DateFormat('EEEE', 'zh_CN').format(selectedDay);
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.event_available,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'$weekday 暂无课程',
style: TextStyle(
fontSize: 18,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
'好好享受课余时光吧!',
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
),
),
],
),
);
}
Widget _buildErrorView(Object error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: Colors.red,
),
const SizedBox(height: 16),
Text(
'加载失败',
style: TextStyle(
fontSize: 18,
color: Colors.grey[700],
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.invalidate(courseListProvider),
child: const Text('重试'),
),
],
),
);
}
Color _getCourseColor(String courseName) {
// 根据课程名称生成一致的颜色
final hash = courseName.hashCode;
final hue = hash.abs() % 360;
return HSVColor.fromAHSV(1, hue / 360, 0.7, 1).toColor();
}
void _goToToday() {
ref.read(selectedDayProvider.notifier).state = DateTime.now();
}
void _showReminderSettings(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (context) => _ReminderSettingsSheet(),
);
}
void _setCourseReminder(Course course) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('已设置 ${course.name} 上课前10分钟提醒'),
action: SnackBarAction(
label: '查看',
onPressed: () {},
),
),
);
}
}
/// 提醒设置底部弹窗
class _ReminderSettingsSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(24),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'课程提醒设置',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
const Divider(height: 24),
SwitchListTile(
title: const Text('上课提醒'),
subtitle: const Text('每节课前10分钟提醒'),
value: true,
onChanged: (value) {},
),
SwitchListTile(
title: const Text('静音提醒'),
subtitle: const Text('在静音模式下也提醒'),
value: false,
onChanged: (value) {},
),
ListTile(
title: const Text('提醒时间'),
subtitle: const Text('提前 10 分钟'),
leading: const Icon(Icons.schedule),
onTap: () {
_showReminderTimePicker(context);
},
),
],
),
);
}
void _showReminderTimePicker(BuildContext context) {
showTimePicker(
context,
initialTime: const TimeOfDay(hour: 7, minute: 50),
).then((time) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('提醒时间已更新为 ${time.format(const HourMinuteFormat())}')),
);
});
}
}
六、应用市场上架合规要点
6.1 module.json5权限配置
json5
// ohos/entry/src/main/module.json5
{
"module": {
"name": "entry",
"type": "entry",
"description": "$string:module_desc",
"mainElement": "EntryAbility",
"deviceTypes": [
"default",
"tablet"
],
"deliveryWithInstall": true,
"installationFree": false,
"pages": "$profile:main_pages",
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"description": "$string:EntryAbility_desc",
"icon": "$media:app_icon",
"label": "$string:EntryAbility_label",
"startWindowIcon": "$media:app_icon",
"startWindowBackground": "$color:start_window_background",
"exported": true,
"skills": [
{
"entities": ["entity.system.home"],
"actions": ["action.system.home"]
}
]
},
{
"name": "CardServiceAbility",
"srcEntry": "./ets/ability/CardServiceAbility.ets",
"description": "$string:CardServiceAbility_desc",
"icon": "$media:card_icon",
"label": "$string:CardServiceAbility_label",
"type": "service",
"backgroundModes": [
"dataTransfer",
"location"
]
}
],
"requestPermissions": [
{
"name": "ohos.permission.INTERNET",
"reason": "$string:internet_reason",
"usedScene": {
"abilities": [
"EntryAbility",
"CardServiceAbility"
],
"when": "always"
}
},
{
"name": "ohos.permission.GET_NETWORK_INFO",
"reason": "$string:network_info_reason"
},
{
"name": "ohos.permission.LOCATION",
"reason": "$string:location_reason",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
{
"name": "ohos.permission.APPROXIMATE_LOCATION",
"reason": "$string:approximate_location_reason"
},
{
"name": "ohos.permission.CAMERA",
"reason": "$string:camera_reason",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
{
"name": "ohos.permission.READ_MEDIA",
"reason": "$string:read_media_reason",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
{
"name": "ohos.permission.NOTIFICATION_CONTROLLER",
"reason": "$string:notification_reason"
},
{
"name": "ohos.permission.NFC_CONTROL",
"reason": "$string:nfc_reason"
},
{
"name": "ohos.permission.MICROPHONE",
"reason": "$string:microphone_reason",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
}
]
}
}
6.2 build-profile.json5构建配置
json5
{
"app": {
"bundleName": "com.campus.pass",
"vendor": "campus",
"versionCode": 1000100,
"versionName": "1.0.1",
"icon": "$media:app_icon",
"label": "$string:app_name",
"description": "$string:app_description",
"minAPIVersion": 9,
"targetAPIVersion": 11,
"apiReleaseType": "Release"
},
"modules": [
{
"name": "entry",
"srcPath": "./entry/src/main",
"targets": [
{
"name": "default",
"applyToProducts": ["default"]
}
]
}
]
}
6.3 strings.json资源文件
json5
// ohos/entry/src/main/resources/base/element/string.json
{
"string": [
{
"name": "app_name",
"value": "校园通"
},
{
"name": "app_description",
"value": "一站式校园服务平台,提供课表查询、校园卡、失物招领等功能"
},
{
"name": "module_desc",
"value": "校园通主模块"
},
{
"name": "EntryAbility_desc",
"value": "校园通应用主入口"
},
{
"name": "EntryAbility_label",
"value": "校园通"
},
{
"name": "CardServiceAbility_desc",
"value": "校园卡服务,支持后台NFC读取和数据同步"
},
{
"name": "CardServiceAbility_label",
"value": "校园卡服务"
},
{
"name": "internet_reason",
"value": "需要网络权限以获取课程信息、校园卡余额等数据"
},
{
"name": "network_info_reason",
"value": "需要网络状态信息以提供更好的服务体验"
},
{
"name": "location_reason",
"value": "需要位置权限以提供地图导航、失物位置标记等功能"
},
{
"name": "approximate_location_reason",
"value": "需要大概位置以显示您周围的失物信息"
},
{
"name": "camera_reason",
"value": "需要相机权限以扫描失物上的二维码、拍摄失物照片"
},
{
"name": "read_media_reason",
"value": "需要读取媒体权限以选择失物照片上传"
},
{
"name": "notification_reason",
"value": "需要推送权限以接收课程提醒、校园通知等信息"
},
{
"name": "nfc_reason",
"value": "需要NFC权限以读取校园卡、图书馆借阅证等"
},
{
"name": "microphone_reason",
"value": "需要麦克风权限以进行语音搜索、语音留言"
}
]
}
6.4 AppGallery上架必备材料
| 材料名称 | 要求说明 | 示例 |
|---|---|---|
| 应用图标 | 512x512 PNG,无圆角 | app_icon.png |
| 应用截图 | 至少3张,最多5张,分辨率1080x2340/2340x1080 | screenshot_1.png |
| 应用描述 | 简洁描述70字内,详细描述2000字内 | 一站式校园服务平台... |
| 应用分类 | 选择正确的应用分类 | 教育/校园生活 |
| 关键词 | 最多10个,逗号分隔 | 校园通,课程表,校园卡,失物招领,课表查询 |
| 隐私政策 | 必需提供隐私政策链接 | https://campus.edu.cn/privacy |
| 用户协议 | 必需提供用户协议链接 | https://campus.edu.cn/agreement |
| 软件著作权 | 需提供著作权证明 | 软著登字第123456号 |
| 版本说明 | 详细描述版本更新内容 | 1. 修复已知问题 2. 优化用户体验 |
七、项目部署与发布
7.1 打包构建命令
bash
# 查看可用设备
flutter devices
# 构建鸿蒙HAP包(调试版本)
flutter build ohos --debug
# 构建鸿蒙HAP包(发布版本)
flutter build ohos --release
# 构建产物位置
# build/ohos/outputs/hap/release/
7.2 签名配置
json5
// build-profile.json5 签名配置
{
"app": {
"signingConfigs": [
{
"name": "default",
"type": "HarmonyOS",
"material": {
"certpath": "./campus_pass.cer",
"storePassword": "your_store_password",
"keyAlias": "campus_pass",
"keyPassword": "your_key_password",
"profile": "./campus_pass.p7b",
"signAlg": "SHA256withECDSA",
"verify": true,
"verifyDetails": []
}
}
]
}
}
7.3 发布流程
1. 准备阶段
├─ 生成应用签名证书
├─ 准备应用图标和截图
├─ 编写应用描述和版本说明
├─ 准备隐私政策和用户协议
└─ 申请软件著作权
2. 提交审核
├─ 登录华为开发者联盟
├─ 创建应用并填写基本信息
├─ 上传HAP安装包
├─ 提交审核材料
└─ 等待审核结果
3. 审核通过
├─ 设置应用上线时间
├─ 配置应用推广
└─ 监控用户反馈
4. 上线后维护
├─ 监控应用崩溃
├─ 收集用户反馈
├─ 及时修复Bug
└─ 定期更新功能