Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions lib/models/course_table.dart
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,20 @@ class SemesterInfo {
return null;
}

SemesterTerm? findSemesterTerm(String semesterId) {
for (final yearEntry in semesters.entries) {
for (final semEntry in yearEntry.value.entries) {
if (semEntry.value != semesterId) continue;

final year = yearEntry.key.split('-').first;
final semester = _semesterNumberForTermName(semEntry.key);
if (semester == null) return null;
return SemesterTerm(year: year, semester: semester);
}
}
return null;
}

List<MapEntry<String, String>> get allSemesters {
final result = <MapEntry<String, String>>[];
for (final yearEntry in semesters.entries) {
Expand All @@ -212,6 +226,45 @@ class SemesterInfo {
}
return result;
}

static String? _semesterNumberForTermName(String termName) {
final normalized = termName.trim().toLowerCase();
if (normalized.isEmpty) return null;

if (normalized == '1' ||
normalized.contains('春') ||
normalized.contains('下') ||
normalized.contains('第二') ||
normalized.contains('spring')) {
return '1';
}

if (normalized == '2' ||
normalized.contains('秋') ||
normalized.contains('上') ||
normalized.contains('第一') ||
normalized.contains('fall') ||
normalized.contains('autumn')) {
return '2';
}

if (normalized == '3' ||
normalized.contains('夏') ||
normalized.contains('暑') ||
normalized.contains('第三') ||
normalized.contains('summer')) {
return '3';
}

return null;
}
}

class SemesterTerm {
final String year;
final String semester;

const SemesterTerm({required this.year, required this.semester});
}

/// Convert EamsCourse list to display Course list, grouping consecutive periods.
Expand Down
241 changes: 230 additions & 11 deletions lib/pages/schedule_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class _SchedulePageState extends State<SchedulePage> {
int _currentWeek = 1;
bool _initialized = false;
bool _exportingCalendar = false;
bool _subscribingCalendar = false;

// Settings
bool _showSaturday = true;
Expand Down Expand Up @@ -350,6 +351,197 @@ class _SchedulePageState extends State<SchedulePage> {
unawaited(_exportCalendar());
}

void _startSubscribeCalendar() {
if (_subscribingCalendar) return;
unawaited(_confirmAndSubscribeCalendar());
}

Future<void> _confirmAndSubscribeCalendar() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
var credentialStorageAgreed = false;

return StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
title: const Text('创建日历订阅'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('如果你之前已经订阅过该学期,之前的订阅链接将会失效。'),
const SizedBox(height: 8),
CheckboxListTile(
value: credentialStorageAgreed,
onChanged: (value) {
setDialogState(() {
credentialStorageAgreed = value ?? false;
});
},
title: Text(
'您理解并同意:订阅日历时,您的登录凭据将会被加密存储到服务器内,并用于课表订阅服务',
style: Theme.of(context).textTheme.bodySmall,
),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: credentialStorageAgreed
? () => Navigator.of(dialogContext).pop(true)
: null,
child: const Text('确定'),
),
],
);
},
);
},
);

if (confirmed != true || !mounted) return;
await _subscribeCalendar();
}

Future<void> _subscribeCalendar() async {
if (_subscribingCalendar) return;

setState(() {
_subscribingCalendar = true;
});

try {
final semesterId = _schedule.selectedSemesterId;
if (semesterId == null || semesterId.isEmpty) {
throw Exception('Missing semesterId');
}
final auth = ServiceProvider.of(context).authService;
final result = await auth.createCalendarSubscription(
semesterId: semesterId,
);

final data = result['data'] as Map<String, dynamic>?;
final subscribeUrl = data?['subscribeUrl'] as String?;
if (!mounted) return;
if (subscribeUrl?.isNotEmpty == true) {
if (isIos() && await _openIosCalendarSubscription(subscribeUrl!)) {
if (!mounted) return;
showAdaptiveFeedback(
context: context,
message: '已打开系统日历订阅',
style: AdaptiveFeedbackStyle.success,
);
return;
}
await _copyCalendarSubscriptionUrl(subscribeUrl!);
return;
}

showAdaptiveFeedback(
context: context,
message: '日历订阅已创建',
style: AdaptiveFeedbackStyle.success,
);
} catch (_) {
if (!mounted) return;
showAdaptiveFeedback(
context: context,
message: '创建日历订阅失败',
style: AdaptiveFeedbackStyle.error,
);
} finally {
if (mounted) {
setState(() {
_subscribingCalendar = false;
});
}
}
}

Future<bool> _openIosCalendarSubscription(String subscribeUrl) async {
final uri = Uri.tryParse(subscribeUrl);
if (uri == null) return false;

final calendarUri = uri.scheme == 'https' || uri.scheme == 'http'
? uri.replace(scheme: 'webcal')
: uri;

try {
return launchUrl(calendarUri, mode: LaunchMode.externalApplication);
} catch (_) {
return false;
}
}

Future<void> _copyCalendarSubscriptionUrl(String subscribeUrl) async {
try {
await Clipboard.setData(ClipboardData(text: subscribeUrl));
if (!mounted) return;
showAdaptiveFeedback(
context: context,
message: '日历订阅链接已复制到剪贴板',
style: AdaptiveFeedbackStyle.success,
);
} catch (_) {
if (!mounted) return;
showAdaptiveFeedback(
context: context,
message: '无法自动复制,请手动复制订阅链接',
style: AdaptiveFeedbackStyle.info,
);
await _showManualCalendarSubscriptionCopy(subscribeUrl);
}
}

Future<void> _showManualCalendarSubscriptionCopy(String subscribeUrl) async {
final controller = TextEditingController(text: subscribeUrl);
final focusNode = FocusNode();
try {
await showDialog<void>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('手动复制订阅链接'),
content: TextField(
controller: controller,
focusNode: focusNode,
readOnly: true,
autofocus: false,
showCursor: false,
keyboardType: TextInputType.none,
enableInteractiveSelection: true,
maxLines: 3,
minLines: 1,
decoration: const InputDecoration(
labelText: '订阅链接',
border: OutlineInputBorder(),
),
onTap: () {
unawaited(_dismissKeyboard());
},
),
actions: [
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('完成'),
),
],
);
},
);
} finally {
focusNode.dispose();
controller.dispose();
}
}

Future<SavedIcsFile> _saveCalendarFile(
IcsSaveLocation location, {
required String calendarName,
Expand Down Expand Up @@ -601,6 +793,13 @@ class _SchedulePageState extends State<SchedulePage> {
? 'arrow.triangle.2.circlepath'
: 'square.and.arrow.up',
),
IosNativeNavigationBarMenuItem(
value: 'subscribeCalendar',
title: _subscribingCalendar ? '正在创建订阅…' : '订阅日历',
sfSymbol: _subscribingCalendar
? 'arrow.triangle.2.circlepath'
: 'calendar.badge.plus',
),
],
),
],
Expand Down Expand Up @@ -665,17 +864,6 @@ class _SchedulePageState extends State<SchedulePage> {
tooltip: 'Next week',
onPressed: _nextWeek,
),
IconButton(
tooltip: _exportingCalendar ? '正在导出课表' : '导出课表',
onPressed: _exportingCalendar ? null : _startExportCalendar,
icon: _exportingCalendar
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.ios_share_rounded),
),
if (isDesktopLayout(context))
IconButton(
key: _viewSettingsAnchorKey,
Expand Down Expand Up @@ -708,6 +896,19 @@ class _SchedulePageState extends State<SchedulePage> {
checked: _showGhostCourses,
child: const Text('显示非本周课程'),
),
const PopupMenuDivider(),
PopupMenuItem(
value: 'exportCalendar',
enabled: !_exportingCalendar,
child: Text(_exportingCalendar ? '正在导出…' : '导出课表'),
),
PopupMenuItem(
value: 'subscribeCalendar',
enabled: !_subscribingCalendar,
child: Text(
_subscribingCalendar ? '正在创建订阅…' : '订阅日历',
),
),
],
),
],
Expand Down Expand Up @@ -884,6 +1085,8 @@ class _SchedulePageState extends State<SchedulePage> {
});
case 'exportCalendar':
_startExportCalendar();
case 'subscribeCalendar':
_startSubscribeCalendar();
}
}

Expand Down Expand Up @@ -969,6 +1172,22 @@ class _SchedulePageState extends State<SchedulePage> {
_startExportCalendar();
},
),
DesktopMenuRow(
leading: _subscribingCalendar
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.calendar_month_outlined, size: 20),
title: Text('订阅日历', style: theme.textTheme.bodyMedium),
onTap: _subscribingCalendar
? null
: () {
close();
_startSubscribeCalendar();
},
),
],
);
},
Expand Down
Loading
Loading