Files
TPSchedule/mobile/lib/features/profile/profile_page.dart
T

325 lines
10 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/app_theme.dart';
import '../../app/app_version.dart';
import '../../data/schedule_file_import.dart';
import '../feedback/feedback_page.dart';
import '../feedback/feedback_submission.dart';
import 'settings_page.dart';
import 'import_preview.dart';
class ProfilePage extends StatelessWidget {
const ProfilePage({
super.key,
required this.controller,
required this.onLogin,
this.schoolName,
this.authStatus = '未登录',
this.onLogout,
this.logoutLabel = '退出学校登录',
this.onUpdateTerm,
this.feedbackSender = const UnconfiguredFeedbackSender(),
this.onCheckUpdates,
this.checkingUpdates = false,
});
final AppController controller;
final VoidCallback onLogin;
final String? schoolName;
final VoidCallback? onLogout;
final VoidCallback? onUpdateTerm;
final String authStatus, logoutLabel;
final FeedbackSender feedbackSender;
final VoidCallback? onCheckUpdates;
final bool checkingUpdates;
Future<void> _import(BuildContext context) async {
try {
final file = await FilePicker.pickFile(
type: FileType.custom,
allowedExtensions: ['json', 'html', 'htm'],
);
if (file == null) return;
final bytes = BytesBuilder(copy: false);
var count = 0;
await for (final chunk in file.readAsByteStream()) {
count += chunk.length;
if (count > 2 * 1024 * 1024) {
throw const FormatException('课表文件不能超过 2 MB');
}
bytes.add(chunk);
}
final draft = ScheduleFileImport.parse(utf8.decode(bytes.takeBytes()));
if (!context.mounted) return;
await showModalBottomSheet<bool>(
context: context,
showDragHandle: true,
isScrollControlled: true,
backgroundColor: canvas,
builder: (_) => ImportPreview(draft: draft, controller: controller),
);
} catch (error) {
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('导入未完成:$error')));
}
}
}
@override
Widget build(BuildContext context) {
final imported = controller.snapshot.importMetadata;
return ListView(
padding: EdgeInsets.fromLTRB(
22,
10,
22,
145 + MediaQuery.paddingOf(context).bottom,
),
children: [
const Text(
'TPSchedule',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const SizedBox(height: 16),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 55,
height: 55,
decoration: BoxDecoration(
color: river.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(19),
),
child: const Icon(
Icons.person_outline_rounded,
size: 30,
color: river,
),
),
const SizedBox(height: 16),
const Text(
'学校课表',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700),
),
const SizedBox(height: 7),
Text(
'${schoolName ?? '选择学校后连接教务系统'}\n$authStatus',
style: TextStyle(
fontSize: 12,
height: 1.8,
color: ink.withValues(alpha: 0.55),
),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: onLogin,
child: const Text('选择学校'),
),
),
if (onLogout != null)
Center(
child: TextButton(
onPressed: onLogout,
child: Text(logoutLabel),
),
),
],
),
),
const SizedBox(height: 18),
SurfaceCard(
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${controller.snapshot.courses.length}',
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.w700,
color: river,
),
),
const Text('已导入的课程安排', style: TextStyle(fontSize: 12)),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
imported == null ? '尚未导入' : '已在本机保存',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 7),
Text(
imported == null
? '离线查看,更安心'
: '${imported.importedAt.month} 月 ${imported.importedAt.day} 日导入',
style: TextStyle(
fontSize: 11,
color: ink.withValues(alpha: 0.4),
),
),
],
),
),
],
),
),
const SizedBox(height: 25),
const Text(
'安排你的每一天',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
SurfaceCard(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 5),
child: Column(
children: [
_tile(
Icons.tune_rounded,
'课表设置',
'颜色、提醒、日历和学期',
() => Navigator.push(
context,
MaterialPageRoute<void>(
builder: (_) => SettingsPage(controller: controller),
),
),
),
Divider(height: 1, color: ink.withValues(alpha: 0.06)),
_tile(
Icons.date_range_rounded,
'更新学期',
controller.snapshot.schoolTerm?.label ?? '选择学校查询学期',
onUpdateTerm ?? onLogin,
),
Divider(height: 1, color: ink.withValues(alpha: 0.06)),
_tile(
Icons.file_download_outlined,
'从文件导入',
'学校 HTML / JSON · 先预览再导入',
() => _import(context),
),
Divider(height: 1, color: ink.withValues(alpha: 0.06)),
_tile(
Icons.auto_awesome_outlined,
'体验示例课表',
'无需登录,预览课表样式',
controller.showDemo,
),
Divider(height: 1, color: ink.withValues(alpha: 0.06)),
_tile(
Icons.chat_bubble_outline_rounded,
'反馈与共建',
'反馈问题和建议 · 我要参与开发',
() => Navigator.push(
context,
MaterialPageRoute<void>(
builder: (_) => FeedbackPage(sender: feedbackSender),
),
),
),
Divider(height: 1, color: ink.withValues(alpha: 0.06)),
ListTile(
key: const Key('check-app-update'),
contentPadding: EdgeInsets.zero,
leading: const Icon(
Icons.system_update_rounded,
color: river,
size: 23,
),
title: const Text(
'检查更新',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
subtitle: Text(
checkingUpdates ? '正在检查…' : '当前版本 $appVersion',
style: const TextStyle(fontSize: 11),
),
trailing: checkingUpdates
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.chevron_right_rounded),
onTap: checkingUpdates ? null : onCheckUpdates,
),
],
),
),
const SizedBox(height: 25),
Center(
child: Column(
children: [
const Text(
'TPSchedule',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
const SizedBox(height: 6),
Text(
'版本 $appVersion',
style: TextStyle(
fontSize: 11,
color: ink.withValues(alpha: 0.45),
),
),
const SizedBox(height: 6),
Text(
'今天也有值得期待的小事。',
style: TextStyle(
fontSize: 11,
color: ink.withValues(alpha: 0.35),
),
),
],
),
),
],
);
}
Widget _tile(
IconData icon,
String title,
String subtitle,
VoidCallback action,
) => ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(icon, color: river, size: 23),
title: Text(
title,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
subtitle: Text(
subtitle,
style: TextStyle(
fontSize: 11,
height: 1.6,
color: ink.withValues(alpha: 0.4),
),
),
trailing: Icon(
Icons.chevron_right_rounded,
color: ink.withValues(alpha: 0.3),
),
onTap: action,
);
}