1.增加了重力感应,也就是横屏响应式布局。

2.修改了沉浸式布局。
3.修改了添加日程和编辑日程页面,增加了重复、提醒等功能。
4.修改了权限问题,如果日历本是只读,则有删除线做标识。同时,对于只读的日程,点击后将不再进入编辑页面,而是展示详情。
5.系统日历的处理。给用户两个选择,第一个选择就是只显示系统日历。第二种选择,用户可以选择一个caldav账户中的某一个日历本,把系统日历中的日程,包括日历日程和应用创建的日程,都读取出来,然后加入到这个日历本下,最后同步到caldav的服务器上,这样的好处是,手机丢失了,或者换了手机品牌型号,手机上的日程仍然在自己的caldav服务器上有一个备份。当然,系统日历中的caldav日历,就不会再读取了。
This commit is contained in:
2026-09-13 20:25:18 +08:00
parent 61bd6fc75f
commit 04fdf3d386
14 changed files with 1211 additions and 210 deletions
+252 -79
View File
@@ -5,6 +5,7 @@ import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore, CalSource, BookPalette } from '../common/AccountStore';
import { EventDb, LocalEvent } from '../common/EventDb';
import { AppSettings } from '../common/AppSettings';
import { DavClient } from '../common/DavClient';
import { SyncEngine } from '../common/SyncEngine';
@@ -30,6 +31,13 @@ struct EventEditPage {
@State isSaving: boolean = false;
@State statusMsg: string = '';
@State isExisting: boolean = false;
@State repeatMode: string = 'none'; // none|daily|workday|weekly|monthly|yearly|custom
@State reminderMin: number = 0; // 提前提醒分钟数,0 = 不提醒
@State pickerShow: boolean = false; // 开始/结束时间选择底部弹层(日期+时间一次选完)
@State pickerIsEnd: boolean = false;
@State pickDate: Date = new Date();
@State pickHour: number = 9;
@State pickMin: number = 0;
private event: LocalEvent | null = null;
aboutToAppear(): Promise<void> {
@@ -41,7 +49,7 @@ struct EventEditPage {
if (context === undefined) {
return;
}
// 收集可写入的日历本(DAV + 本机)
// 收集可写入的日历本(DAV 可写日历本 + 本机);只读日历本不出现在新建/编辑选择中
const sources: CalSourceWithHref[] = await CalendarDataBridge.loadWritableSources(context);
const choices: BookChoice[] = [];
for (const s of sources) {
@@ -68,6 +76,8 @@ struct EventEditPage {
this.startMs = loaded.startTime;
this.endMs = loaded.endTime;
this.chosenKey = loaded.calKey;
this.repeatMode = loaded.rrule !== '' ? this.modeFromRrule(loaded.rrule) : 'none';
this.reminderMin = loaded.reminder;
return;
}
}
@@ -98,62 +108,81 @@ struct EventEditPage {
return `${p(d.getHours())}:${p(d.getMinutes())}`;
}
private pickStartDate(): void {
const cur = new Date(this.startMs);
DatePickerDialog.show({
start: new Date(2000, 0, 1),
end: new Date(2050, 11, 31),
selected: cur,
onDateAccept: (value: Date) => {
const keep = new Date(this.startMs);
const newStart: number = new Date(value.getFullYear(), value.getMonth(), value.getDate(),
keep.getHours(), keep.getMinutes()).getTime();
const dur: number = this.endMs - this.startMs;
this.startMs = newStart;
this.endMs = this.allDay ? newStart + 86399999 : newStart + dur;
}
});
/** 重复模式 → RRULE 字符串(RruleUtil 已支持这些规则) */
private rruleForMode(mode: string): string {
if (mode === 'daily') {
return 'FREQ=DAILY';
}
if (mode === 'workday') {
return 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR';
}
if (mode === 'weekly') {
return 'FREQ=WEEKLY';
}
if (mode === 'monthly') {
return 'FREQ=MONTHLY';
}
if (mode === 'yearly') {
return 'FREQ=YEARLY';
}
return '';
}
private pickStartTime(): void {
const cur = new Date(this.startMs);
TimePickerDialog.show({
selected: cur,
onAccept: (value: TimePickerResult) => {
const d = new Date(this.startMs);
const newStart: number = new Date(d.getFullYear(), d.getMonth(), d.getDate(),
value.hour, value.minute).getTime();
const dur: number = this.endMs - this.startMs;
this.startMs = newStart;
this.endMs = newStart + dur;
}
});
/** RRULE → 重复模式(无法识别的规则归为 custom,保存时保留原规则) */
private modeFromRrule(rrule: string): string {
const u: string = rrule.toUpperCase();
if (u.includes('FREQ=DAILY')) {
return 'daily';
}
if (u.includes('FREQ=WEEKLY')) {
const hasWeekday: boolean = u.includes('MO') && u.includes('TU') && u.includes('WE')
&& u.includes('TH') && u.includes('FR');
return hasWeekday ? 'workday' : 'weekly';
}
if (u.includes('FREQ=MONTHLY')) {
return 'monthly';
}
if (u.includes('FREQ=YEARLY')) {
return 'yearly';
}
return 'custom';
}
private pickEndDate(): void {
const cur = new Date(this.endMs);
DatePickerDialog.show({
start: new Date(2000, 0, 1),
end: new Date(2050, 11, 31),
selected: cur,
onDateAccept: (value: Date) => {
const keep = new Date(this.endMs);
this.endMs = new Date(value.getFullYear(), value.getMonth(), value.getDate(),
keep.getHours(), keep.getMinutes()).getTime();
}
});
private reminderLabel(m: number): string {
if (m === 0) {
return '不提醒';
}
if (m < 60) {
return `提前${m}分钟`;
}
if (m < 1440) {
return `提前${m / 60}小时`;
}
return `提前${m / 1440}天`;
}
private pickEndTime(): void {
const cur = new Date(this.endMs);
TimePickerDialog.show({
selected: cur,
onAccept: (value: TimePickerResult) => {
const d = new Date(this.endMs);
this.endMs = new Date(d.getFullYear(), d.getMonth(), d.getDate(),
value.hour, value.minute).getTime();
}
});
/** 打开时间选择弹层:日期 + 时间一次选完 */
private openPicker(isEnd: boolean): void {
this.pickerIsEnd = isEnd;
const base = new Date(isEnd ? this.endMs : this.startMs);
this.pickDate = new Date(base.getFullYear(), base.getMonth(), base.getDate());
this.pickHour = base.getHours();
this.pickMin = base.getMinutes();
this.pickerShow = true;
}
private applyPicker(): void {
const d = this.pickDate;
const picked: number = new Date(d.getFullYear(), d.getMonth(), d.getDate(),
this.pickHour, this.pickMin).getTime();
if (this.pickerIsEnd) {
// 全天日程的结束存为"当天 23:59:59.999"(排他日期前 1 毫秒)
this.endMs = this.allDay ? picked + 86399999 : picked;
} else {
const dur: number = Math.max(0, this.endMs - this.startMs);
this.startMs = picked;
this.endMs = this.allDay ? picked + 86399999 : picked + dur;
}
}
private toggleAllDay(): void {
@@ -186,8 +215,9 @@ struct EventEditPage {
if (this.isSaving || !this.validate()) {
return;
}
if (this.event !== null && this.event.recurring) {
this.statusMsg = '重复日程(系列中的一天)暂不支持修改,请到服务器端调整重复规则';
// 仅阻止"单次覆盖实例"RECURRENCE-ID)的修改;重复主事件(含 RRULE)允许编辑
if (this.event !== null && this.event.recurring && this.event.rrule === '') {
this.statusMsg = '重复日程的单次修改暂不支持,请到服务器端调整重复规则';
return;
}
this.isSaving = true;
@@ -208,6 +238,15 @@ struct EventEditPage {
e.isAllDay = this.allDay;
e.calKey = book !== null ? book.calKey : 'local';
e.href = book !== null ? book.href : '';
// 重复规则与提醒
if (this.repeatMode === 'custom') {
// 无法识别的既有规则原样保留
e.rrule = this.event !== null ? this.event.rrule : '';
} else {
e.rrule = this.rruleForMode(this.repeatMode);
}
e.recurring = e.rrule !== '';
e.reminder = this.reminderMin;
if (isNew) {
e.uid = `syncal-${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
e.remotePath = encodeURIComponent(e.uid) + '.ics';
@@ -242,8 +281,8 @@ struct EventEditPage {
if (this.event === null || this.isSaving) {
return;
}
if (this.event.recurring) {
this.statusMsg = '重复日程(系列中的一天)暂不支持删除,请到服务器端调整重复规则';
if (this.event !== null && this.event.recurring && this.event.rrule === '') {
this.statusMsg = '重复日程的单次删除暂不支持,请到服务器端调整重复规则';
return;
}
this.isSaving = true;
@@ -274,11 +313,12 @@ struct EventEditPage {
build() {
Column({ space: 14 }) {
// 顶部
// 顶部(与内容区左右对齐,避免贴边误触)
Row({ space: 6 }) {
Text('取消')
.fontSize(16)
.fontColor($r('app.color.brand'))
.padding({ left: 4, right: 4, top: 8, bottom: 8 })
.onClick(() => {
router.back();
})
@@ -292,11 +332,13 @@ struct EventEditPage {
.fontSize(16)
.fontColor(this.isSaving ? $r('app.color.text_hint') : $r('app.color.brand'))
.fontWeight(FontWeight.Medium)
.padding({ left: 4, right: 4, top: 8, bottom: 8 })
.onClick(() => {
this.save();
})
}
.width('100%')
.padding({ left: 16, right: 16 })
Scroll() {
Column({ space: 14 }) {
@@ -327,7 +369,7 @@ struct EventEditPage {
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
// 时间卡片
// 时间卡片(单击任一行:底部弹层中日期+时间一次选完)
Column({ space: 10 }) {
this.timeRow('开始', true)
Divider().color($r('app.color.shadow_color'))
@@ -338,6 +380,53 @@ struct EventEditPage {
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
// 重复
Column({ space: 8 }) {
Text('重复')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
this.repeatChip('none', '不重复')
this.repeatChip('daily', '每天')
this.repeatChip('workday', '工作日')
this.repeatChip('weekly', '每周')
this.repeatChip('monthly', '每月')
this.repeatChip('yearly', '每年')
if (this.repeatMode === 'custom') {
// 服务器来的复杂规则(INTERVAL/COUNT 等)标记为自定义,保存时原样保留
this.repeatChip('custom', '自定义')
}
}
.width('100%')
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
// 提醒
Column({ space: 8 }) {
Text('提醒')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
this.reminderChip(0)
this.reminderChip(5)
this.reminderChip(10)
this.reminderChip(15)
this.reminderChip(30)
this.reminderChip(60)
this.reminderChip(1440)
}
.width('100%')
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
// 日历本选择
Column({ space: 8 }) {
Text('日历本')
@@ -424,6 +513,12 @@ struct EventEditPage {
.height('100%')
.padding({ top: 12 })
.backgroundColor($r('app.color.page_bg'))
.bindSheet($$this.pickerShow, this.dateTimePickerSheet(), {
height: 380,
showClose: false,
dragBar: true,
backgroundColor: $r('app.color.card_bg')
})
}
@Builder
@@ -433,32 +528,102 @@ struct EventEditPage {
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(36)
Text(this.fmtDate(isStart ? this.startMs : this.endMs))
Text(this.allDay
? this.fmtDate(isStart ? this.startMs : this.endMs)
: `${this.fmtDate(isStart ? this.startMs : this.endMs)} ${this.fmtTime(isStart ? this.startMs : this.endMs)}`)
.fontSize(15)
.fontColor($r('app.color.brand'))
.onClick(() => {
if (isStart) {
this.pickStartDate();
} else {
this.pickEndDate();
}
})
if (!this.allDay) {
Text(this.fmtTime(isStart ? this.startMs : this.endMs))
.fontSize(15)
.fontColor($r('app.color.brand'))
.onClick(() => {
if (isStart) {
this.pickStartTime();
} else {
this.pickEndTime();
}
})
}
Blank()
.layoutWeight(1)
Text('')
.fontSize(16)
.fontColor($r('app.color.text_hint'))
}
.width('100%')
.padding({ left: 8, right: 8, top: 8, bottom: 8 })
.padding({ left: 8, right: 8, top: 10, bottom: 10 })
.onClick(() => {
this.openPicker(isStart);
})
}
@Builder
repeatChip(mode: string, label: string) {
Text(label)
.fontSize(12)
.fontColor(this.repeatMode === mode
? $r('app.color.button_text') : $r('app.color.text_primary'))
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.borderRadius(14)
.margin({ right: 8, bottom: 8 })
.backgroundColor(this.repeatMode === mode ? $r('app.color.brand') : $r('app.color.chip_off_bg'))
.onClick(() => {
this.repeatMode = mode;
})
}
@Builder
reminderChip(minutes: number) {
Text(this.reminderLabel(minutes))
.fontSize(12)
.fontColor(this.reminderMin === minutes
? $r('app.color.button_text') : $r('app.color.text_primary'))
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.borderRadius(14)
.margin({ right: 8, bottom: 8 })
.backgroundColor(this.reminderMin === minutes ? $r('app.color.brand') : $r('app.color.chip_off_bg'))
.onClick(() => {
this.reminderMin = minutes;
})
}
/** 开始/结束时间选择弹层:左侧日期、右侧时间(全天时只有日期),一次确定 */
@Builder
dateTimePickerSheet() {
Column({ space: 14 }) {
Text(this.pickerIsEnd ? '选择结束时间' : '选择开始时间')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 12 })
Row({ space: 6 }) {
DatePicker({
start: new Date(2000, 0, 1),
end: new Date(2050, 11, 31),
selected: this.pickDate
})
.onDateChange((value: Date) => {
this.pickDate = value;
})
.layoutWeight(1)
.height(210)
if (!this.allDay) {
TimePicker({
selected: new Date(2000, 0, 1, this.pickHour, this.pickMin)
})
.onChange((value: TimePickerResult) => {
this.pickHour = value.hour;
this.pickMin = value.minute;
})
.layoutWeight(1)
.height(210)
}
}
.width('100%')
Button(this.allDay ? '确定日期' : '确定')
.width('100%')
.height(44)
.borderRadius(12)
.fontSize(15)
.backgroundColor($r('app.color.brand'))
.onClick(() => {
this.applyPicker();
this.pickerShow = false;
})
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 24 })
}
}
@@ -467,8 +632,15 @@ class CalendarDataBridge {
static async loadWritableSources(context: common.Context): Promise<CalSourceWithHref[]> {
const result: CalSourceWithHref[] = [];
const accounts: DavAccount[] = await AccountStore.loadAll(context);
const manualKeys: string[] = await AppSettings.getManualReadonlyKeys(context);
for (const acc of accounts) {
for (let i = 0; i < acc.calendarHrefs.length; i++) {
// 只读日历本(服务器无写权限,或用户手动标记只读)不可选
const detected: boolean = acc.calendarWritable.length > i
? acc.calendarWritable[i] !== '0' : true;
if (!detected || manualKeys.includes(`${acc.id}_${i}`)) {
continue;
}
const s = new CalSourceWithHref();
s.calKey = `${acc.id}_${i}`;
let name: string = i < acc.calendarNames.length ? acc.calendarNames[i] : '';
@@ -479,6 +651,7 @@ class CalendarDataBridge {
let color: string = i < acc.calendarColors.length ? AccountStore.normalizeColor(acc.calendarColors[i]) : '';
s.color = color !== '' ? color : BookPalette.colorFor(i);
s.href = acc.calendarHrefs[i];
s.writable = true;
result.push(s);
}
}
+308 -102
View File
@@ -1,7 +1,7 @@
// entry/src/main/ets/pages/Index.ets
// 同步日历主界面:月视图(左右滑动翻月)/ 周视图 / 日视图 / 日程列表
// 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天"
import { router } from '@kit.ArkUI';
import { mediaquery, router } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { BusinessError, pasteboard } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore';
@@ -15,6 +15,7 @@ import { AppSettings } from '../common/AppSettings';
import { EventDb, LocalEvent } from '../common/EventDb';
import { RruleUtil } from '../common/RruleUtil';
import { IcsUtil } from '../common/IcsUtil';
import { SystemCalendarImport } from '../common/SystemCalendarImport';
/** 月视图单元格 */
class MonthCell {
@@ -43,6 +44,8 @@ struct Index {
@State syncing: boolean = false;
@State loading: boolean = true;
@State menuOpen: boolean = false; // 顶部 ≡ 下拉菜单
@State isLandscape: boolean = false; // 横屏:月视图切左右双栏(左月历/右当日日程)
private landscapeListener: mediaquery.MediaQueryListener | null = null;
private accounts: DavAccount[] = [];
private sources: CalSource[] = [];
private swiperController: SwiperController = new SwiperController();
@@ -62,6 +65,7 @@ struct Index {
this.displayMonth = now.getMonth();
this.selectedDate = this.startOfDay(now.getTime());
this.rebuildPages();
this.initLandscapeListener();
this.initPermissionAndLoad();
}
@@ -70,6 +74,20 @@ struct Index {
clearInterval(this.autoSyncTimer);
this.autoSyncTimer = -1;
}
if (this.landscapeListener !== null) {
this.landscapeListener.off('change');
this.landscapeListener = null;
}
}
/** 监听横竖屏:旋转后 isLandscape 驱动月视图在上下/左右布局间切换 */
private initLandscapeListener(): void {
this.landscapeListener =
this.getUIContext().getMediaQuery().matchMediaSync('(orientation: landscape)');
this.isLandscape = this.landscapeListener.matches;
this.landscapeListener.on('change', (result: mediaquery.MediaQueryResult) => {
this.isLandscape = result.matches;
});
}
/** 从设置页/账号页返回时刷新(同步间隔、系统日历开关立即生效),并处理编辑账号后的待同步 */
@@ -329,6 +347,12 @@ struct Index {
}
let ok: number = 0;
let failMsg: string = '';
// 备份模式:先把系统本地日历导入目标日历本(幂等),再随正常同步推送上服务器
try {
await SystemCalendarImport.importIfNeeded(context);
} catch (err) {
// 导入失败不影响正常同步
}
for (const acc of this.accounts) {
if (acc.type !== TYPE_CALDAV) {
continue;
@@ -373,15 +397,53 @@ struct Index {
}
private openEvent(e: DisplayEvent): void {
if (e.isSystem) {
this.getUIContext().getPromptAction()
.showToast({ message: '系统日历日程,请在系统日历 App 中编辑' });
// 只读日历本或系统日历日程:无法保存修改,直接显示详情
if (e.isSystem || !e.writable) {
this.showEventDetail(e);
return;
}
AppStorage.setOrCreate<number>('pendingEventId', e.id);
router.pushUrl({ url: 'pages/EventEditPage' });
}
/** 只读日程详情弹窗 */
private showEventDetail(e: DisplayEvent): void {
const lines: string[] = [];
if (e.isAllDay) {
lines.push(`时间:全天 ${this.fmtDateCn(e.startTime)}`);
if (this.spansDays(e)) {
lines.push(` ~ ${this.fmtDateCn(e.endTime)}`);
}
} else if (this.spansDays(e)) {
lines.push(`时间:${this.fmtDateCn(e.startTime)} ${this.fmtTime(e.startTime)}`);
lines.push(` ~ ${this.fmtDateCn(e.endTime)} ${this.fmtTime(e.endTime)}`);
} else {
lines.push(`时间:${this.fmtDateCn(e.startTime)} ${this.fmtTime(e.startTime)} ~ ${this.fmtTime(e.endTime)}`);
}
if (e.location !== '') {
lines.push(`地点:${e.location}`);
}
if (e.recurring) {
lines.push('重复:是');
}
if (e.calName !== '') {
lines.push(`日历本:${e.calName}${e.isSystem ? '' : '(只读)'}`);
}
if (e.description !== '') {
lines.push(`备注:${e.description}`);
}
this.getUIContext().showAlertDialog({
title: e.title === '' ? '(无标题)' : e.title,
message: lines.join('\n'),
autoCancel: true,
alignment: DialogAlignment.Center,
primaryButton: {
value: '关闭',
action: (): void => {}
}
});
}
private addEvent(): void {
AppStorage.setOrCreate<number>('pendingEventId', 0);
AppStorage.setOrCreate<number>('pendingEventDate', this.selectedDate);
@@ -707,65 +769,109 @@ struct Index {
@Builder
monthBody() {
Column() {
// 星期表头
if (this.isLandscape) {
// 横屏(平板适配):左右双栏——左侧月历,右侧当日日程
Row() {
ForEach(WEEK_LABELS, (w: string) => {
Text(w)
.fontSize(12)
.fontColor($r('app.color.text_hint'))
.textAlign(TextAlign.Center)
.layoutWeight(1)
}, (w: string) => w)
Column() {
this.monthWeekHeader()
this.monthSwiper()
}
.layoutWeight(3)
.height('100%')
Divider()
.vertical(true)
.height('92%')
.strokeWidth(1)
.color($r('app.color.shadow_color'))
Column() {
this.dayPanelHeader()
this.eventList()
}
.layoutWeight(2)
.height('100%')
}
.width('100%')
.padding({ left: 12, right: 12 })
// 三页月格,左右滑动切换月份
Swiper(this.swiperController) {
ForEach(this.monthPages, (cells: MonthCell[]) => {
Column({ space: 2 }) {
ForEach(this.chunkCells(cells), (week: MonthCell[], idx: number) => {
Row({ space: 2 }) {
ForEach(week, (cell: MonthCell) => {
this.dayCell(cell)
}, (cell: MonthCell) => `${cell.dateMs}_${cell.dateMs === this.selectedDate}`)
}
.width('100%')
}, (week: MonthCell[], idx: number) => String(idx))
}
.width('100%')
}, (cells: MonthCell[]) => String(cells[0].dateMs))
.layoutWeight(1)
} else {
// 竖屏:月历在上、当日日程在下(保持原布局)
Column() {
this.monthWeekHeader()
this.monthSwiper()
this.dayPanelHeader()
this.eventList()
}
.index(1)
.loop(false)
.indicator(false)
.width('100%')
.onChange((index: number) => {
this.handleSwiperChange(index);
})
.layoutWeight(1)
}
}
// 当日日程列表
Row({ space: 8 }) {
Text(this.fmtDateCn(this.selectedDate))
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_secondary'))
Blank()
Text(this.fmtMonthTitle())
/** 月视图星期表头 */
@Builder
monthWeekHeader() {
Row() {
ForEach(WEEK_LABELS, (w: string) => {
Text(w)
.fontSize(12)
.fontColor($r('app.color.text_hint'))
}
.width('100%')
.padding({ left: 20, right: 20, top: 6 })
.gesture(LongPressGesture().onAction(() => {
this.showOccurrenceDebug(this.selectedDate);
}))
this.eventList()
.textAlign(TextAlign.Center)
.layoutWeight(1)
}, (w: string) => w)
}
.width('100%')
.layoutWeight(1)
.padding({ left: 12, right: 12 })
}
/** 月视图三页 Swiper(横屏时占满剩余高度并均分每周行,竖屏保持自然高度) */
@Builder
monthSwiper() {
Swiper(this.swiperController) {
ForEach(this.monthPages, (cells: MonthCell[]) => {
Column({ space: 2 }) {
ForEach(this.chunkCells(cells), (week: MonthCell[], idx: number) => {
Row({ space: 2 }) {
ForEach(week, (cell: MonthCell) => {
this.dayCell(cell)
}, (cell: MonthCell) => `${cell.dateMs}_${cell.dateMs === this.selectedDate}`)
}
.width('100%')
.layoutWeight(this.isLandscape ? 1 : 0)
}, (week: MonthCell[], idx: number) => String(idx))
}
.width('100%')
// 注意:不要给页面设百分比高度——竖屏时 Swiper 是自然高度,
// 子组件 height('100%') 对 auto 父容器解析为 0,整月网格会塌陷
}, (cells: MonthCell[]) => String(cells[0].dateMs))
}
.index(1)
.loop(false)
.indicator(false)
.width('100%')
.layoutWeight(this.isLandscape ? 1 : 0)
.onChange((index: number) => {
this.handleSwiperChange(index);
})
}
/** 当日日程标题行(长按可触发重复日程调试) */
@Builder
dayPanelHeader() {
Row({ space: 8 }) {
Text(this.fmtDateCn(this.selectedDate))
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_secondary'))
Blank()
Text(this.fmtMonthTitle())
.fontSize(12)
.fontColor($r('app.color.text_hint'))
}
.width('100%')
.padding({ left: 20, right: 20, top: 6 })
.gesture(LongPressGesture().onAction(() => {
this.showOccurrenceDebug(this.selectedDate);
}))
}
/** 调试(临时):长按月视图日期标题,检查重复日程在该日的首次发生情况 */
@@ -890,48 +996,144 @@ struct Index {
@Builder
weekBody() {
Column() {
Row({ space: 16 }) {
Blank()
Text('本周')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.onClick(() => {
this.goThisWeek();
if (this.isLandscape) {
// 横屏(平板适配):左侧一周日期,右侧选中日日程
Row() {
Column() {
Text('本周')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 4, bottom: 8 })
.onClick(() => {
this.goThisWeek();
})
this.weekStripVertical()
}
.layoutWeight(1)
.height('100%')
Divider()
.vertical(true)
.height('92%')
.strokeWidth(1)
.color($r('app.color.shadow_color'))
Column() {
this.dayPanelHeader()
this.eventList()
}
.layoutWeight(2)
.height('100%')
}
.width('100%')
.layoutWeight(1)
.gesture(
SwipeGesture({ direction: SwipeDirection.Horizontal })
.onAction((event: GestureEvent) => {
if (Math.abs(event.angle) > 90) {
this.switchWeek(1);
} else {
this.switchWeek(-1);
}
})
Blank()
)
} else {
// 竖屏:保持原上下结构
Column() {
Row({ space: 16 }) {
Blank()
Text('本周')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.onClick(() => {
this.goThisWeek();
})
Blank()
}
.width('100%')
.padding({ left: 20, right: 20, top: 4, bottom: 4 })
this.weekStrip()
Row({ space: 8 }) {
Text(this.fmtDateCn(this.selectedDate))
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_secondary'))
Blank()
}
.width('100%')
.padding({ left: 20, right: 20, top: 8 })
this.eventList()
}
.width('100%')
.padding({ left: 20, right: 20, top: 4, bottom: 4 })
this.weekStrip()
Row({ space: 8 }) {
Text(this.fmtDateCn(this.selectedDate))
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_secondary'))
Blank()
}
.width('100%')
.padding({ left: 20, right: 20, top: 8 })
this.eventList()
.layoutWeight(1)
.gesture(
SwipeGesture({ direction: SwipeDirection.Horizontal })
.onAction((event: GestureEvent) => {
// 左滑角度约 ±180|angle|>90)→ 下一周;右滑约 0° → 上一周(与月视图 Swiper 方向一致)
if (Math.abs(event.angle) > 90) {
this.switchWeek(1);
} else {
this.switchWeek(-1);
}
})
)
}
}
/** 周视图竖排日期条(横屏左栏:周一~周日从上到下,可上下滚动) */
@Builder
weekStripVertical() {
Scroll() {
Column({ space: 2 }) {
ForEach(this.weekStripDays(), (cell: MonthCell) => {
Row({ space: 10 }) {
Text(WEEK_LABELS[(new Date(cell.dateMs).getDay() + 6) % 7])
.fontSize(12)
.fontColor(cell.dateMs === this.selectedDate
? $r('app.color.text_secondary') : $r('app.color.text_hint'))
.width(18)
.textAlign(TextAlign.Center)
Text(String(cell.day))
.fontSize(15)
.fontWeight(cell.dateMs === this.selectedDate || cell.isToday
? FontWeight.Bold : FontWeight.Normal)
.fontColor(cell.dateMs === this.selectedDate
? $r('app.color.button_text') : $r('app.color.text_primary'))
.width(32)
.height(32)
.textAlign(TextAlign.Center)
.borderRadius(16)
.backgroundColor(cell.dateMs === this.selectedDate
? $r('app.color.selected_bg')
: (cell.isToday ? $r('app.color.today_bg') : Color.Transparent))
}
.width('100%')
.height(44)
.borderRadius(10)
.padding({ left: 10 })
.backgroundColor(cell.dateMs === this.selectedDate
? $r('app.color.chip_off_bg') : Color.Transparent)
.onClick(() => {
this.selectedDate = cell.dateMs;
})
}, (cell: MonthCell) => `${cell.dateMs}_${cell.dateMs === this.selectedDate}`)
}
.width('100%')
.padding({ left: 8, right: 8, top: 2, bottom: 8 })
.constraintSize({ minHeight: '100%' })
}
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.Top)
.width('100%')
.layoutWeight(1)
.gesture(
SwipeGesture({ direction: SwipeDirection.Horizontal })
.onAction((event: GestureEvent) => {
// 左滑角度约 ±180|angle|>90)→ 下一周;右滑约 0° → 上一周(与月视图 Swiper 方向一致)
if (Math.abs(event.angle) > 90) {
this.switchWeek(1);
} else {
this.switchWeek(-1);
}
})
)
}
/** 单日日程列表(周视图使用) */
@@ -952,7 +1154,7 @@ struct Index {
}
}
.width('100%')
.padding({ left: 20, right: 20, top: 4, bottom: 90 })
.padding({ left: 20, right: 20, top: 4, bottom: 24 })
.constraintSize({ minHeight: '100%' })
}
.layoutWeight(1)
@@ -1000,7 +1202,7 @@ struct Index {
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.padding({ left: 20, right: 20, top: 6, bottom: 90 })
.padding({ left: 20, right: 20, top: 6, bottom: 24 })
.cachedCount(8)
}
@@ -1097,7 +1299,7 @@ struct Index {
}
}
.width('100%')
.padding({ left: 20, right: 20, top: 8, bottom: 90 })
.padding({ left: 20, right: 20, top: 8, bottom: 24 })
.constraintSize({ minHeight: '100%' })
}
.layoutWeight(1)
@@ -1196,7 +1398,7 @@ struct Index {
.borderRadius(6)
.backgroundColor($r('app.color.chip_off_bg'))
}
// 所属日历本:最右侧、垂直居中,颜色同日历本
// 所属日历本:最右侧、垂直居中,颜色同日历本;只读日历本加删除线标识
if (e.calName !== '') {
Text(e.calName)
.fontSize(11)
@@ -1204,6 +1406,7 @@ struct Index {
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '30%' })
.decoration({ type: e.writable ? TextDecorationType.None : TextDecorationType.LineThrough })
}
}
.alignItems(VerticalAlign.Center)
@@ -1221,23 +1424,26 @@ struct Index {
dayCell(cell: MonthCell) {
Column({ space: 2 }) {
Text(String(cell.day))
.fontSize(13)
.fontSize(this.isLandscape ? 12 : 13)
.fontWeight(cell.isToday || cell.dateMs === this.selectedDate ? FontWeight.Bold : FontWeight.Normal)
.fontColor(!cell.inMonth
? $r('app.color.text_hint')
: (cell.dateMs === this.selectedDate ? $r('app.color.button_text') : $r('app.color.text_primary')))
.width(26)
.height(26)
.width(this.isLandscape ? 22 : 26)
.height(this.isLandscape ? 22 : 26)
.textAlign(TextAlign.Center)
.borderRadius(13)
.borderRadius(this.isLandscape ? 11 : 13)
.backgroundColor(cell.dateMs === this.selectedDate
? $r('app.color.selected_bg')
: (cell.isToday ? $r('app.color.today_bg') : Color.Transparent))
Text(cell.lunar)
.fontSize(8)
.fontColor(cell.dateMs === this.selectedDate
? $r('app.color.text_secondary') : $r('app.color.text_hint'))
.maxLines(1)
if (!this.isLandscape) {
// 横屏高度有限,省略农历腾出空间
Text(cell.lunar)
.fontSize(8)
.fontColor(cell.dateMs === this.selectedDate
? $r('app.color.text_secondary') : $r('app.color.text_hint'))
.maxLines(1)
}
}
.layoutWeight(1)
.padding({ top: 4, bottom: 4 })
+255 -2
View File
@@ -1,19 +1,32 @@
// entry/src/main/ets/pages/SettingsPage.ets
// 设置页:系统日历混合显示开关 + 自动同步间隔
// 设置页:系统日历模式(仅显示/备份到 CalDAV)+ 混合显示开关 + 自动同步间隔 + 后台同步
import { router } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { AppSettings } from '../common/AppSettings';
import { BackgroundSyncService } from '../common/BackgroundSyncService';
import { AccountStore, DavAccount } from '../common/AccountStore';
import { LogUtil } from '../common/LogUtil';
const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60];
/** 日历本选项(备份目标 / 只读标记共用) */
class BackupTarget {
calKey: string = '';
label: string = '';
serverWritable: boolean = true; // 服务器探测结果
}
@Entry
@Component
struct SettingsPage {
@State showSystem: boolean = true;
@State intervalMinutes: number = 1;
@State backgroundSync: boolean = false;
@State sysMode: string = 'display'; // display | backup
@State backupKey: string = '';
@State backupTargets: BackupTarget[] = [];
@State allBooks: BackupTarget[] = []; // 全部 DAV 日历本(只读标记管理用)
@State manualKeys: string[] = []; // 手动标记只读的 calKey
private context?: common.Context;
aboutToAppear(): void {
@@ -32,6 +45,128 @@ struct SettingsPage {
AppSettings.getBackgroundSync(ctx).then((v: boolean): void => {
this.backgroundSync = v;
});
AppSettings.getSysCalMode(ctx).then((v: string): void => {
this.sysMode = v;
});
AppSettings.getManualReadonlyKeys(ctx).then((v: string[]): void => {
this.manualKeys = v;
});
this.loadBackupSettings();
this.loadAllBooks();
}
/** 加载全部 DAV 日历本(含探测到的写权限,只读标记管理用) */
private async loadAllBooks(): Promise<void> {
if (this.context === undefined) {
return;
}
const accounts: DavAccount[] = await AccountStore.loadAll(this.context);
const books: BackupTarget[] = [];
for (const acc of accounts) {
if (acc.type !== 'caldav') {
continue;
}
for (let i = 0; i < acc.calendarHrefs.length; i++) {
const b = new BackupTarget();
b.calKey = `${acc.id}_${i}`;
const bookName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本 ${i + 1}`;
b.label = `${acc.name} · ${bookName}`;
b.serverWritable = acc.calendarWritable.length > i ? acc.calendarWritable[i] !== '0' : true;
books.push(b);
}
}
this.allBooks = books;
}
/** 手动标记/取消只读 */
private async toggleManualBook(b: BackupTarget): Promise<void> {
if (this.context === undefined) {
return;
}
const list: string[] = [...this.manualKeys];
const idx: number = list.indexOf(b.calKey);
if (idx >= 0) {
list.splice(idx, 1);
} else {
list.push(b.calKey);
}
this.manualKeys = list;
await AppSettings.setManualReadonlyKeys(this.context, list);
await this.loadBackupSettings(); // 备份目标候选同步排除
this.getUIContext().getPromptAction().showToast({
message: idx >= 0 ? '已恢复为可写,返回首页生效' : '已标记为只读,返回首页生效'
});
}
/** 日历本当前只读状态文案 */
private bookStateLabel(b: BackupTarget): string {
if (this.manualKeys.includes(b.calKey)) {
return '只读(手动)';
}
return b.serverWritable ? '可写' : '只读';
}
/** 加载备份目标候选(可写 DAV 日历本)+ 当前选择 */
private async loadBackupSettings(): Promise<void> {
if (this.context === undefined) {
return;
}
const accounts: DavAccount[] = await AccountStore.loadAll(this.context);
const targets: BackupTarget[] = [];
for (const acc of accounts) {
if (acc.type !== 'caldav') {
continue;
}
for (let i = 0; i < acc.calendarHrefs.length; i++) {
const writable: boolean = acc.calendarWritable.length > i
? acc.calendarWritable[i] !== '0' : true;
if (!writable) {
continue;
}
const t = new BackupTarget();
t.calKey = `${acc.id}_${i}`;
const bookName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本 ${i + 1}`;
t.label = `${acc.name} · ${bookName}`;
t.serverWritable = writable;
targets.push(t);
}
}
// 手动标记只读的本不可作为备份目标
const manual: string[] = await AppSettings.getManualReadonlyKeys(this.context);
this.backupTargets = targets.filter((t: BackupTarget): boolean => !manual.includes(t.calKey));
this.backupTargets = targets;
const saved: string = await AppSettings.getBackupCalKey(this.context);
this.backupKey = targets.some((t: BackupTarget): boolean => t.calKey === saved) ? saved : '';
}
private async saveSysMode(mode: string): Promise<void> {
if (this.context === undefined) {
return;
}
this.sysMode = mode;
await AppSettings.setSysCalMode(this.context, mode);
if (mode === 'backup' && this.backupKey === '') {
// 自动选中第一个可写日历本
if (this.backupTargets.length > 0) {
this.backupKey = this.backupTargets[0].calKey;
await AppSettings.setBackupCalKey(this.context, this.backupKey);
}
}
this.getUIContext().getPromptAction().showToast({
message: mode === 'backup'
? '已开启备份:下次同步时把系统本地日程导入所选日历本'
: '已切换为仅显示:不再把系统日程备份到 CalDAV'
});
}
private async saveBackupTarget(calKey: string): Promise<void> {
if (this.context === undefined) {
return;
}
this.backupKey = calKey;
await AppSettings.setBackupCalKey(this.context, calKey);
this.getUIContext().getPromptAction()
.showToast({ message: '备份目标已更新,下次同步生效' });
}
private async saveShowSystem(value: boolean): Promise<void> {
@@ -96,7 +231,125 @@ struct SettingsPage {
Scroll() {
Column({ space: 12 }) {
// 系统日历混合显示
// 系统日历模式:仅显示 / 备份到 CalDAV
Column({ space: 8 }) {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('系统日历')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text('选择手机系统日历日程的处理方式')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
Select([{ value: '仅显示(不做备份)' }, { value: '备份到 CalDAV 日历本' }] as SelectOption[])
.selected(this.sysMode === 'backup' ? 1 : 0)
.value(this.sysMode === 'backup' ? '备份到 CalDAV 日历本' : '仅显示(不做备份)')
.fontColor($r('app.color.text_primary'))
.font({ size: 14 })
.optionFont({ size: 14 })
.selectedOptionFont({ size: 14 })
.width('100%')
.onSelect((index: number) => {
const mode: string = index === 1 ? 'backup' : 'display';
if (mode !== this.sysMode) {
this.saveSysMode(mode);
}
})
if (this.sysMode === 'backup') {
Text(this.backupTargets.length > 0
? '备份目标(可写日历本):导入后随同步上传服务器,换机/丢失也有备份'
: '没有可写的 CalDAV 日历本,请先添加账号或检查日历本权限')
.fontSize(12)
.fontColor(this.backupTargets.length > 0
? $r('app.color.text_secondary') : $r('app.color.error'))
.width('100%')
if (this.backupTargets.length > 0) {
Select(this.backupTargets.map((t: BackupTarget): SelectOption => {
return { value: t.label } as SelectOption;
}) as SelectOption[])
.selected(this.backupTargets.findIndex((t: BackupTarget): boolean => t.calKey === this.backupKey))
.value(this.backupTargets.find((t: BackupTarget): boolean => t.calKey === this.backupKey)?.label
?? '请选择日历本')
.fontColor($r('app.color.text_primary'))
.font({ size: 14 })
.optionFont({ size: 14 })
.selectedOptionFont({ size: 14 })
.width('100%')
.onSelect((index: number) => {
if (index >= 0 && index < this.backupTargets.length) {
this.saveBackupTarget(this.backupTargets[index].calKey);
}
})
}
}
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
// 日历本只读标记(部分服务器不在 CalDAV 层拒绝写入,自动探测无法区分时手动标记)
Column({ space: 8 }) {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('日历本只读标记')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text('点击日历本切换只读标记:标记后其中日程仅可查看详情,新建日程时不再显示')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
if (this.allBooks.length === 0) {
Text('暂无 CalDAV 日历本')
.fontSize(12)
.fontColor($r('app.color.text_hint'))
.width('100%')
}
ForEach(this.allBooks, (b: BackupTarget) => {
Row({ space: 8 }) {
Text(b.label)
.fontSize(13)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text(this.bookStateLabel(b))
.fontSize(11)
.fontColor(this.bookStateLabel(b) === '可写'
? $r('app.color.success') : $r('app.color.error'))
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(10)
.backgroundColor(this.bookStateLabel(b) === '可写'
? $r('app.color.success_bg') : $r('app.color.error_bg'))
}
.width('100%')
.padding({ top: 6, bottom: 6 })
.onClick(() => {
this.toggleManualBook(b);
})
}, (b: BackupTarget) => `${b.calKey}_${this.manualKeys.includes(b.calKey) ? 1 : 0}`)
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
// 混合显示系统日历
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('混合显示系统日历')