diff --git a/AppScope/app.json5 b/AppScope/app.json5 index 5491a79..3cf6e5c 100644 --- a/AppScope/app.json5 +++ b/AppScope/app.json5 @@ -1,7 +1,7 @@ { "app": { - "bundleName": "com.example.synccalendar", - "vendor": "example", + "bundleName": "synccalendar.yangyq.net", + "vendor": "yangyq", "versionCode": 1000000, "versionName": "1.0.0", "buildVersion": "1", diff --git a/build-profile.json5 b/build-profile.json5 index 3665959..838563d 100644 --- a/build-profile.json5 +++ b/build-profile.json5 @@ -7,11 +7,11 @@ "material": { "certpath": "C:\\Users\\Yongquan\\.ohos\\config\\default_SyncCalendar_0sxf9mFmzO-SlvjrtFdry7WYo0XNlBNQv53r6bYgUXU=.cer", "keyAlias": "debugKey", - "keyPassword": "0000001B33AD9F6D120B4EECB746F81D96D510F30604A697D2B980246F99EE96912DFC3993DD956CD73E60", + "keyPassword": "0000001B2A717AAE3B463934D7117A852F00EE5A851BE39B576A32220F81D791848450D464F2E017471F64", "profile": "C:\\Users\\Yongquan\\.ohos\\config\\default_SyncCalendar_0sxf9mFmzO-SlvjrtFdry7WYo0XNlBNQv53r6bYgUXU=.p7b", "signAlg": "SHA256withECDSA", "storeFile": "C:\\Users\\Yongquan\\.ohos\\config\\default_SyncCalendar_0sxf9mFmzO-SlvjrtFdry7WYo0XNlBNQv53r6bYgUXU=.p12", - "storePassword": "0000001B34494825D65DEA8FE982119843372F798BDED6A2A483601114F60B9C3A947B6E08F85DB9F3BA15" + "storePassword": "0000001B354C1491B727A5491186A36C464DDCD646FA5ECCF62F29785D3444143FDD089E52B2A4359B8F4F" } } ], diff --git a/entry/src/main/ets/common/AppSettings.ets b/entry/src/main/ets/common/AppSettings.ets index 8cb8881..9efe4f5 100644 --- a/entry/src/main/ets/common/AppSettings.ets +++ b/entry/src/main/ets/common/AppSettings.ets @@ -16,6 +16,31 @@ export class AppSettings { private static readonly KEY_MUTED_REMINDER: string = 'muted_reminder_keys'; // 提醒静音的 calKey private static readonly KEY_REMINDER_TICK: string = 'reminder_tick'; // 应用内提醒上次检查时间戳 private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成 + private static readonly KEY_DEFAULT_VIEW: string = 'default_view'; // 打开 App 默认视图 + + /** 打开 App 后默认展示的视图:'month' | 'week' | 'agenda'(默认 month) */ + static async getDefaultView(context: common.Context): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + const v: string = await store.get(AppSettings.KEY_DEFAULT_VIEW, 'month') as string; + return (v === 'week' || v === 'agenda') ? v : 'month'; + } catch (err) { + return 'month'; + } + } + + static async setDefaultView(context: common.Context, view: string): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + await store.put(AppSettings.KEY_DEFAULT_VIEW, view); + await store.flush(); + } catch (err) { + const e = err as BusinessError; + console.error(`保存默认视图失败: ${e.message}`); + } + } /** * 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。 diff --git a/entry/src/main/ets/common/CardDataService.ets b/entry/src/main/ets/common/CardDataService.ets index c22c0e8..c9f13c3 100644 --- a/entry/src/main/ets/common/CardDataService.ets +++ b/entry/src/main/ets/common/CardDataService.ets @@ -7,6 +7,7 @@ import { preferences } from '@kit.ArkData'; import { CalendarDataService, DisplayEvent } from './CalendarDataService'; import { LunarUtil } from './LunarUtil'; import { LogUtil } from './LogUtil'; +import { TimelineUtil, DayTimeline } from './TimelineUtil'; /** 卡片单条日程(按天分组:组内全天事件在前、有时间的按开始时间排序) */ export class CardItem { @@ -26,6 +27,14 @@ export class CardItem { nowLineBelow: boolean = false; // 今天所有有时间日程已结束:红线画在最后一条下方 } +/** 卡片「全天/跨天」日程(**必须带 eventKey**,否则卡片侧 ForEach 的 key 会重复 → 只渲染出第一条) */ +export class CardAllDay { + eventKey: string = ''; + title: string = ''; + color: string = '#007DFF'; + isAllDay: boolean = true; +} + /** 卡片整体数据 */ export class CardData { eventsJson: string = '[]'; @@ -38,6 +47,15 @@ export class CardData { ongoingCount: number = 0; // 此刻正在进行的日程条数(2x2 卡片红点提示用) // 2x4 卡片右侧「正在进行」列表:与 eventsJson 同结构(CardItem[]),只含此刻进行中/下一个日程 ongoingJson: string = '[]'; + // 4x4 / 6x4 卡片:今日 0-23 时间轴(TimelineBlock[] JSON)与当前时间纵向比例 + timelineJson: string = '[]'; + nowRatio: number = 0; // 当前时间在当天时间轴上的比例(0~1),卡片画红线用 + nowLabel: string = ''; // 当前时间文字(如 '14:05') + allDayJson: string = '[]'; // 全天/跨天日程(CardAllDay[] JSON,卡片画在最上方) + // ===== 卡片翻页(上一天 / 下一天 / 回到今天)===== + dayOffset: number = 0; // 相对今天的天数偏移(0=今天) + isToday: boolean = true; // 当前显示的是否为今天(非今天不画红线) + dayCount: number = 0; // 当前显示日的日程条数(含全天) } export class CardDataService { @@ -47,6 +65,19 @@ export class CardDataService { // formIds 持久化(App 重启后内存注册表会清空,从 preferences 恢复,保证推送不丢卡片) private static readonly PREF_STORE: string = 'card_form_ids'; private static readonly PREF_KEY: string = 'form_ids'; + // 每张卡片的"翻页偏移"(formId → 相对今天的天数)。卡片不能滚动,靠翻页看别的日期。 + // 放在这里是为了让 FormExtension(按钮翻页)与 App 内刷新共用同一份状态。 + private static offsets: Map = new Map(); + + static setDayOffset(formId: string, off: number): void { + CardDataService.offsets.set(formId, off); + } + static getDayOffset(formId: string): number { + return CardDataService.offsets.get(formId) ?? 0; + } + static clearDayOffset(formId: string): void { + CardDataService.offsets.delete(formId); + } /** 注册卡片:内存 + 持久化 */ static async registerForm(context: common.Context, formId: string): Promise { @@ -96,11 +127,21 @@ export class CardDataService { if (CardDataService.formIds.length === 0) { return; } - const data: CardData = await CardDataService.buildCardData(context); - const binding: formBindingData.FormBindingData = - formBindingData.createFormBindingData(data); + // 各卡片可能翻到了不同日期 → 按偏移分组构建,避免把用户翻走的日期拽回今天 + const cache: Map = + new Map(); + const fallback: formBindingData.FormBindingData = + formBindingData.createFormBindingData(await CardDataService.buildCardData(context, 0)); + cache.set(0, fallback); const stale: string[] = []; for (const formId of CardDataService.formIds) { + const off: number = CardDataService.getDayOffset(formId); + let binding: formBindingData.FormBindingData | undefined = cache.get(off); + if (binding === undefined) { + binding = formBindingData.createFormBindingData( + await CardDataService.buildCardData(context, off)); + cache.set(off, binding); + } try { await formProvider.updateForm(formId, binding); } catch (err) { @@ -123,20 +164,29 @@ export class CardDataService { } /** 组装卡片数据(异步:查询本地库) */ - static async buildCardData(context: common.Context): Promise { + /** 构建卡片数据。 + * @param dayOffset 相对"今天"的天数偏移(卡片翻页用:负数=过去,0=今天,正数=未来) + * 非今天时不输出 nowRatio(红线不显示)。 */ + static async buildCardData(context: common.Context, dayOffset: number = 0): Promise { const data = new CardData(); try { LogUtil.init(context); const now = new Date(); const weekCn: string[] = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; const fullWeekCn: string[] = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']; - data.dateText = `${now.getMonth() + 1}月${now.getDate()}日 ${weekCn[now.getDay()]}`; - data.lunarText = LunarUtil.lunarDayText(now.getTime()); - // 2x2 卡片强化字段:月-日(x月x日,年份冗余省略)、星期全名、今日日程条数 - data.dateMd = `${now.getMonth() + 1}月${now.getDate()}日`; - data.weekday = fullWeekCn[now.getDay()]; - const start: number = CardDataService.startOfDay(now.getTime()); - const end: number = start + 60 * 86400000; + const todayStart: number = CardDataService.startOfDay(now.getTime()); + const baseStart: number = todayStart + dayOffset * 86400000; // 当前卡片显示的"那一天"0 点 + const base: Date = new Date(baseStart); + const prefix: string = dayOffset === 0 ? '今天 ' : (dayOffset === 1 ? '明天 ' : + (dayOffset === -1 ? '昨天 ' : '')); + data.dateText = `${prefix}${base.getMonth() + 1}月${base.getDate()}日 ${weekCn[base.getDay()]}`; + data.lunarText = LunarUtil.lunarDayText(baseStart); + data.dateMd = `${base.getMonth() + 1}月${base.getDate()}日`; + data.weekday = fullWeekCn[base.getDay()]; + data.dayOffset = dayOffset; + data.isToday = dayOffset === 0; + const start: number = baseStart < todayStart ? baseStart : todayStart; + const end: number = (baseStart > todayStart ? baseStart : todayStart) + 60 * 86400000; const sources = await CalendarDataService.loadSources(context); const events: DisplayEvent[] = await CalendarDataService.loadEvents(context, start, end, sources); // 1) 过滤:保留"今天 0 点以来"的日程(今天已结束的也显示,否则重复日程的 @@ -249,22 +299,14 @@ export class CardDataService { // 今日日程条数:item.date === '今天' 的均为今天分组(含全天/跨天进行中) data.todayCount = items.filter((i: CardItem): boolean => i.date === '今天').length; - // ===== 2x4 卡片右侧:正在进行 / 下一个 日程(最多 3 条,作为 2x2 的右侧扩展)===== - // 规则:① 进行中或已开始未结束的(含全天/跨天,按 0 点起算)优先,最多 2 条; - // ② 不足时用"今天还没开始的下一个有时间日程"补 1 条。全部按开始时间升序。 - const floorMs = Math.max(nowMs, todayKey); - const ongoingAll: DisplayEvent[] = upcoming - .filter((e: DisplayEvent): boolean => e.startTime <= nowMs && e.endTime > floorMs) + // ===== 2x4 卡片右侧:**当前时间之后最近的 2 条**日程 ===== + // 规则:只取"还没开始"的有时间日程(startTime >= now), + // **排除全天 / 跨天**,按开始时间升序取前 2 条。 + // 不再区分"进行中 / 即将开始" —— 卡片上不再显示这些字样。 + const upcomingNext: DisplayEvent[] = upcoming + .filter((e: DisplayEvent): boolean => !isDayLong(e) && e.startTime >= nowMs) .sort((a: DisplayEvent, b: DisplayEvent): number => a.startTime - b.startTime); - let ongoingPick: DisplayEvent[] = ongoingAll.slice(0, 2); - if (ongoingPick.length < 2) { - const next: DisplayEvent | undefined = upcoming - .filter((e: DisplayEvent): boolean => !isDayLong(e) && e.startTime > nowMs) - .sort((a: DisplayEvent, b: DisplayEvent): number => a.startTime - b.startTime)[0]; - if (next !== undefined) { - ongoingPick = ongoingPick.concat([next]); - } - } + const ongoingPick: DisplayEvent[] = upcomingNext.slice(0, 2); const ongoingItems: CardItem[] = []; for (const e of ongoingPick) { const dayLong: boolean = isDayLong(e); @@ -278,11 +320,33 @@ export class CardDataService { oi.color = e.color; oi.startMs = e.startTime; oi.endMs = e.endTime; - oi.isNow = e.startTime <= nowMs && e.endTime > nowMs; + oi.isNow = false; ongoingItems.push(oi); } data.ongoingJson = JSON.stringify(ongoingItems); - LogUtil.write(`卡片数据刷新:${items.length} 条,今日 ${data.todayCount} 条,进行中 ${ongoingItems.length} 条`); + // ===== 4x4 / 6x4 卡片:**当前显示日**的时间轴(色块按时间平铺,冲突分列)===== + const dayEvents: DisplayEvent[] = events.filter((e: DisplayEvent): boolean => + e.startTime < baseStart + 86400000 && e.endTime > baseStart); + const tl: DayTimeline = TimelineUtil.build(dayEvents, baseStart); + TimelineUtil.markNow(tl.blocks, nowMs); + data.timelineJson = JSON.stringify(tl.blocks); + // 注意:不能直接 stringify(DisplayEvent) —— 它没有 eventKey 字段, + // 卡片侧 ForEach 会拿到 undefined 导致 key 重复(表现为"全天只显示一条") + const allDayItems: CardAllDay[] = []; + for (const e of tl.allDay) { + const ad = new CardAllDay(); + ad.eventKey = TimelineUtil.keyOf(e); + ad.title = e.title === '' ? '(无标题)' : e.title; + ad.color = e.color; + ad.isAllDay = e.isAllDay; + allDayItems.push(ad); + } + data.allDayJson = JSON.stringify(allDayItems); + // 只有"今天"才给红线比例;翻到别的日期时给 -1(卡片侧 nowR<0 → 不画红线) + data.nowRatio = data.isToday ? TimelineUtil.nowRatio(nowMs) : -1; + data.nowLabel = TimelineUtil.nowLabel(nowMs); + data.dayCount = tl.blocks.length + tl.allDay.length; + LogUtil.write(`卡片数据刷新:${items.length} 条,今日 ${data.todayCount} 条,进行中 ${ongoingItems.length} 条,时间轴 ${tl.blocks.length} 块`); } catch (err) { LogUtil.write(`卡片数据刷新失败: ${JSON.stringify(err)}`); } diff --git a/entry/src/main/ets/common/TimelineUtil.ets b/entry/src/main/ets/common/TimelineUtil.ets new file mode 100644 index 0000000..17d7f57 --- /dev/null +++ b/entry/src/main/ets/common/TimelineUtil.ets @@ -0,0 +1,360 @@ +// entry/src/main/ets/common/TimelineUtil.ets +// 时间轴布局:把"某一天"的日程按真实时间平铺到 0-23 小时的纵向格子上。 +// +// 设计约定(与用户确认): +// - 时间粒度 1 小时 = 1 格(0 点到 23 点,共 24 格),色块高度按实际时长换算。 +// - 同一时间重叠(冲突)的日程横向平分宽度:1 条 = 100%,2 条 = 50%,3 条 ≈ 33.3%… +// - 色块背景色 = 所属日历本颜色。 +// - 全天/跨天日程不占时间轴,单独返回,由视图画在最上方(0 点之前)。 +// - 当前时间红线按"现在"在时间轴上的纵向比例定位(hoursFromDayStart / 24)。 +// +// ArkTS 约束:所有"多字段返回值"必须是命名 class,不能用内联对象字面量类型。 +import { DisplayEvent } from './CalendarDataService'; + +/** 时间轴上的一个日程色块(位置/尺寸都用相对比例,视图侧乘以实际高度即可) */ +export class TimelineBlock { + eventKey: string = ''; // 与视图 ForEach key 一致 + title: string = ''; + timeText: string = ''; // '09:00 - 10:30' + color: string = '#007DFF'; + topRatio: number = 0; // 距 0 点的比例 0~1(乘时间轴总高) + heightRatio: number = 0; // 高度比例(最小高度由视图侧兜底) + leftRatio: number = 0; // 横向起始比例 0~1(冲突平分) + widthRatio: number = 1; // 横向宽度比例 + groupIndex: number = 0; // 冲突组序号(时间上连通重叠的一簇,视图侧按组分行) + laneIndex: number = 0; // 组内的"列号"(贪心分列结果,列号相同的日程互不重叠、可复用一列) + isNow: boolean = false; // 正在进行中(视图侧可高亮) + // 原始时间,点击/详情使用 + ev: DisplayEvent | null = null; +} + +/** 一天的完整时间轴布局结果 */ +export class DayTimeline { + dateKey: number = 0; // 该天 0 点毫秒(ForEach key 去重 / 调试用) + allDay: DisplayEvent[] = []; // 全天 / 跨天(画在 0 点之前) + blocks: TimelineBlock[] = []; // 时间轴色块 + hasTimed: boolean = false; // 当天是否有有时间日程 +} + +/** 冲突分组:一组时间上互相重叠的日程(用于横向平分宽度) */ +class OverlapGroup { + items: DisplayEvent[] = []; + endMax: number = 0; +} + +/** 视图/卡片渲染用的"冲突组":一组互相重叠的日程横向平分宽度,每条各占一"列"(lane)。 + * 纵向:组本身占据 [startRatio, endRatio] 一段,组内每条按自己的 topRatio 定位。 + * 不同组在时间上互不重叠(由分组算法保证),因此可以按时间顺序自上而下排列。 */ +export class TimelineGroup { + startRatio: number = 0; // 组内最早开始(距 0 点比例 0~1) + endRatio: number = 0; // 组内最晚结束 + laneCount: number = 1; // 列数 = 组内"最大同时重叠数"(贪心分列结果,不是组内条数) + gapBeforeRatio: number = 0; // 与上一个组(或 0 点)之间的空隙比例 + blocks: TimelineBlock[] = []; // 组内全部色块(按开始时间升序) + lanes: TimelineBlock[][] = []; // 按列组织:lanes[i] = 第 i 列上的色块(同列互不重叠,纵向依次排列) +} + +/** 视窗范围(小时):按当天日程的起止截断,"今天"还要容纳当前时刻 */ +export class ViewRange { + startHour: number = 0; + endHour: number = 0; +} + +export class TimelineUtil { + /** 单个格子的高度(vp):调用方(视图/卡片)用同一个常量换算 */ + static readonly HOUR_UNIT: number = 44; + + /** 0 点毫秒 */ + static startOfDay(ms: number): number { + const d = new Date(ms); + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + } + + private static spansDays(e: DisplayEvent): boolean { + return TimelineUtil.startOfDay(e.endTime) > TimelineUtil.startOfDay(e.startTime); + } + + /** 日程唯一 key(与列表 ForEach 保持一致) */ + static keyOf(e: DisplayEvent): string { + return `${e.isSystem ? 's' : 'l'}${e.id}_${e.startTime}`; + } + + private static fmtTime(ms: number): string { + const d = new Date(ms); + const p = (n: number): string => n < 10 ? '0' + n : String(n); + return `${p(d.getHours())}:${p(d.getMinutes())}`; + } + + /** + * 构建某一天的时间轴布局。 + * @param events 与 dateMs 这一天相交的全部日程 + * @param dateMs 该天 0 点毫秒 + */ + static build(events: DisplayEvent[], dateMs: number): DayTimeline { + const res = new DayTimeline(); + const dayStart: number = TimelineUtil.startOfDay(dateMs); + const dayEnd: number = dayStart + 86400000; + res.dateKey = dayStart; + + // 1) 分类:全天 → 顶部;有时间 → 时间轴(无论是否当天开始,都按与本日的交集定位) + // 注意:多日日程由上层"逐天展开"后传入,这里只需按交集裁切即可,不能再整条丢到顶部, + // 否则跨天日程的时间段会全部消失(曾导致"除全天外一条都看不到")。 + const timed: DisplayEvent[] = []; + for (const e of events) { + if (e.isAllDay) { + res.allDay.push(e); + } else if (e.startTime <= dayStart && e.endTime >= dayEnd) { + // 覆盖整天(多日日程的中间日):占满整列会挡住别的日程 → 放顶部"跨天"条 + res.allDay.push(e); + } else if (e.endTime > dayStart && e.startTime < dayEnd) { + // 与"本日 0 点~24 点"有真实时间交集 → 画在时间轴上(起始/结束按交集裁切) + timed.push(e); + } + } + res.allDay.sort((a: DisplayEvent, b: DisplayEvent): number => a.startTime - b.startTime); + res.hasTimed = timed.length > 0; + if (timed.length === 0) { + return res; + } + + // 2) 时间轴色块:按开始时间排序,划分"冲突组"(互相重叠的归为一组) + timed.sort((a: DisplayEvent, b: DisplayEvent): number => a.startTime - b.startTime); + const groups: OverlapGroup[] = []; + for (const e of timed) { + const last: OverlapGroup | undefined = groups.length > 0 ? groups[groups.length - 1] : undefined; + if (last !== undefined && e.startTime < last.endMax) { + last.items.push(e); + last.endMax = Math.max(last.endMax, e.endTime); + } else { + const g = new OverlapGroup(); + g.items = [e]; + g.endMax = e.endTime; + groups.push(g); + } + } + + // 3) 组内"贪心分列"(区间图着色):按开始时间升序,每条放进"结束时间 <= 它开始时间"的 + // **编号最小**的那一列。列数 = 该组"最大同时重叠数",而不是组内条数。 + // 例:A 7:30-9:30、B 8:20-9:00、C 9:15-9:55 —— B 与 C 并不重叠,可共用同一列, + // 因此只需 2 列(旧算法按"传递性重叠"把三者归为一组 → 错误地分成 3 列)。 + let groupIdx: number = 0; + for (const g of groups) { + const laneEnd: number[] = []; // 每列当前的最后结束时间(毫秒) + const laneOf: number[] = []; // 每条日程分配到的列号 + for (const e of g.items) { + let lane: number = -1; + for (let i = 0; i < laneEnd.length; i++) { + if (laneEnd[i] <= e.startTime) { + lane = i; + break; + } + } + if (lane < 0) { + lane = laneEnd.length; + laneEnd.push(e.endTime); + } else { + laneEnd[lane] = e.endTime; + } + laneOf.push(lane); + } + const n: number = laneEnd.length > 0 ? laneEnd.length : 1; // 列数 = 最大同时重叠数 + for (let i = 0; i < g.items.length; i++) { + const e = g.items[i]; + const b = new TimelineBlock(); + b.eventKey = TimelineUtil.keyOf(e); + b.groupIndex = groupIdx; + b.title = e.title === '' ? '(无标题)' : e.title; + b.timeText = `${TimelineUtil.fmtTime(e.startTime)} - ${TimelineUtil.fmtTime(e.endTime)}`; + b.color = e.color; + // 与今天的交集(跨天日程已排除,这里都是日内) + const s: number = Math.max(e.startTime, dayStart); + const en: number = Math.min(e.endTime, dayEnd); + b.topRatio = (s - dayStart) / 86400000; + b.heightRatio = Math.max((en - s) / 86400000, 1 / 96); // 最短约 15 分钟,避免零高 + b.laneIndex = laneOf[i]; + b.widthRatio = 1 / n; + b.leftRatio = laneOf[i] / n; + b.isNow = false; + b.ev = e; + res.blocks.push(b); + } + groupIdx++; + } + // 兜底:保证 blocks 严格按 topRatio 升序,供视图侧 Blank 间隙求差无累积漂移 + res.blocks.sort((a: TimelineBlock, b: TimelineBlock): number => a.topRatio - b.topRatio); + return res; + } + + /** + * 由平坦的色块列表重建"冲突分组":视图 / 卡片按组分行、组内按列(lane)并排渲染。 + * 依赖 TimelineBlock.groupIndex / leftRatio / topRatio / heightRatio。 + * 卡片侧只拿到 timelineJson(TimelineBlock[] 序列化,含 groupIndex),因此用它同样能重建。 + */ + static groupsOf(blocks: TimelineBlock[]): TimelineGroup[] { + const map: Map = new Map(); + for (const b of blocks) { + const arr: TimelineBlock[] | undefined = map.get(b.groupIndex); + if (arr === undefined) { + map.set(b.groupIndex, [b]); + } else { + arr.push(b); + } + } + const idxs: number[] = Array.from(map.keys()).sort((a: number, b: number): number => a - b); + const out: TimelineGroup[] = []; + let prevEnd: number = 0; + for (const gi of idxs) { + const bs: TimelineBlock[] = map.get(gi) ?? []; + // 按开始时间升序(同开始时间则长的在前,保证分列稳定) + bs.sort((a: TimelineBlock, b: TimelineBlock): number => { + if (a.topRatio !== b.topRatio) { + return a.topRatio - b.topRatio; + } + return b.heightRatio - a.heightRatio; + }); + // 贪心分列:每条放进"结束时间 <= 它开始时间"的编号最小的列 → 列数 = 最大同时重叠数 + // 这里用比例而非原始毫秒重算,卡片侧(只有序列化后的 timelineJson)也能正确分列。 + const lanes: TimelineBlock[][] = []; + const laneEnd: number[] = []; + for (const b of bs) { + let li: number = -1; + for (let i = 0; i < laneEnd.length; i++) { + if (laneEnd[i] <= b.topRatio + 0.0000001) { + li = i; + break; + } + } + const be: number = b.topRatio + b.heightRatio; + if (li < 0) { + li = lanes.length; + lanes.push([b]); + laneEnd.push(be); + } else { + lanes[li].push(b); + laneEnd[li] = be; + } + b.laneIndex = li; + } + const lc: number = lanes.length > 0 ? lanes.length : 1; + for (const b of bs) { + b.widthRatio = 1 / lc; + b.leftRatio = b.laneIndex / lc; + } + const g = new TimelineGroup(); + g.blocks = bs; + g.lanes = lanes; + g.laneCount = lc; + let s: number = 1; + let e: number = 0; + for (const b of bs) { + if (b.topRatio < s) { + s = b.topRatio; + } + const be: number = b.topRatio + b.heightRatio; + if (be > e) { + e = be; + } + } + g.startRatio = s; + g.endRatio = e; + g.gapBeforeRatio = s - prevEnd < 0 ? 0 : s - prevEnd; + prevEnd = e; + out.push(g); + } + return out; + } + + /** + * 计算"需要显示的小时区间"(视窗截断): + * - 起点 = 最早日程所在整点(无日程默认 8 点),终点 = 最晚日程结束时刻向上取整(无日程默认 20 点) + * - includeNowHour=true(今天)时,把当前时刻所在小时并进来,保证红线可见 + * 视图渲染与"列表视图按天计算卡片高度"共用这一份逻辑,避免两边算法漂移。 + */ + static viewRange(blocks: TimelineBlock[], includeNowHour: boolean, nowMs: number): ViewRange { + const rg = new ViewRange(); + let s: number = -1; + let e: number = -1; + for (const b of blocks) { + const bh: number = Math.floor(b.topRatio * 24); + const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001); + if (s < 0 || bh < s) { + s = bh; + } + if (e < 0 || eh > e) { + e = eh; + } + } + if (s < 0) { + s = 8; + } + if (e < 0) { + e = 20; + } + if (includeNowHour) { + const nh: number = Math.floor(TimelineUtil.nowRatio(nowMs) * 24); + if (nh < s) { + s = nh; + } + if (nh + 1 > e) { + e = nh + 1; + } + } + if (s < 0) { + s = 0; + } + if (s > 23) { + s = 23; + } + if (e > 24) { + e = 24; + } + if (e <= s) { + e = s + 1 > 24 ? 24 : s + 1; + } + rg.startHour = s; + rg.endHour = e; + return rg; + } + + /** 某天"有时间"的日程是否已全部结束:最晚结束比例 <= 当前时刻比例。 + * (全天/跨天色块不参与 —— 它们没有"结束时刻"概念。) + * 当只剩全天日程、或当天无任何定时日程时,同样返回 true(视为"已无进行中的日程")。 + * 用于:决定是否显示"当前时间红线",以及是否把"当前小时"并入时间轴视窗。 */ + static allTimedEnded(blocks: TimelineBlock[], nowMs: number): boolean { + let maxEnd: number = 0; + for (const b of blocks) { + const e: number = b.topRatio + b.heightRatio; + if (e > maxEnd) { + maxEnd = e; + } + } + return TimelineUtil.nowRatio(nowMs) >= maxEnd - 0.0001; + } + + /** 标记"正在进行"的色块(start<=now 1) { + r = 1; + } + return r; + } + + /** 当前时间的小时刻度文字(画在红线左侧),如 '14:05' */ + static nowLabel(ms: number): string { + return TimelineUtil.fmtTime(ms); + } +} diff --git a/entry/src/main/ets/entryformability/EntryFormAbility.ets b/entry/src/main/ets/entryformability/EntryFormAbility.ets index 09949a4..29c17e2 100644 --- a/entry/src/main/ets/entryformability/EntryFormAbility.ets +++ b/entry/src/main/ets/entryformability/EntryFormAbility.ets @@ -8,8 +8,8 @@ import { LogUtil } from '../common/LogUtil'; export default class EntryFormAbility extends FormExtensionAbility { /** 组装更新数据并推送到指定卡片 */ - private pushData(formId: string): void { - CardDataService.buildCardData(this.context).then((data: CardData): void => { + private pushData(formId: string, dayOffset: number = 0): void { + CardDataService.buildCardData(this.context, dayOffset).then((data: CardData): void => { const binding: formBindingData.FormBindingData = formBindingData.createFormBindingData(data); formProvider.updateForm(formId, binding).catch((err: BusinessError): void => { @@ -42,12 +42,32 @@ export default class EntryFormAbility extends FormExtensionAbility { LogUtil.init(this.context); LogUtil.write(`系统触发卡片更新: formId=${formId}`); CardDataService.registerForm(this.context, formId); - this.pushData(formId); + // 保留用户翻到的日期(系统定时刷新不应把用户翻走的日期拽回今天) + this.pushData(formId, CardDataService.getDayOffset(formId)); } onRemoveForm(formId: string): void { LogUtil.init(this.context); LogUtil.write(`移除卡片: formId=${formId}`); CardDataService.unregisterForm(this.context, formId); + CardDataService.clearDayOffset(formId); + } + + /** 卡片上「‹ / › / 回到今天」按钮触发:message 里带 pageAction=prev|next|today */ + onFormEvent(formId: string, message: string): void { + LogUtil.init(this.context); + const cur: number = CardDataService.getDayOffset(formId); + let next: number = cur; + // 不用 JSON 解析:不同版本 message 的格式可能是 {params:{...}} 或直接 {...},直接子串匹配更稳 + if (message.indexOf('prev') >= 0) { + next = cur - 1; + } else if (message.indexOf('next') >= 0) { + next = cur + 1; + } else if (message.indexOf('today') >= 0) { + next = 0; + } + CardDataService.setDayOffset(formId, next); + LogUtil.write(`卡片翻页: formId=${formId} ${cur} → ${next}`); + this.pushData(formId, next); } } diff --git a/entry/src/main/ets/pages/AccountsPage.ets b/entry/src/main/ets/pages/AccountsPage.ets index 187cfad..3f2640a 100644 --- a/entry/src/main/ets/pages/AccountsPage.ets +++ b/entry/src/main/ets/pages/AccountsPage.ets @@ -3,7 +3,7 @@ import { router } from '@kit.ArkUI'; import { common } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; -import { DavAccount, AccountStore, TYPE_KEYS, TYPE_CALDAV, TYPE_CARDDAV, TYPE_WEBDAV } from '../common/AccountStore'; +import { DavAccount, AccountStore, TYPE_KEYS, TYPE_CALDAV, TYPE_CARDDAV } from '../common/AccountStore'; import { SyncEngine } from '../common/SyncEngine'; import { EditNavParams } from './EditAccountPage'; import { EventDb } from '../common/EventDb'; @@ -15,7 +15,6 @@ import { ScreenKeeper } from '../common/ScreenKeeper'; @Component struct AccountsPage { @State accounts: DavAccount[] = []; - @State showTypeMenu: boolean = false; @State syncing: boolean = false; @State syncingId: string = ''; @@ -103,9 +102,9 @@ struct AccountsPage { } } - private openAddPage(type: string): void { - this.showTypeMenu = false; - AppStorage.setOrCreate('pendingAccountType', type); + private openAddPage(): void { + // 目前只支持 CalDAV:点 + 直接进入添加 CalDAV 账号页(不再弹类型选择菜单) + AppStorage.setOrCreate('pendingAccountType', TYPE_CALDAV); router.pushUrl({ url: 'pages/AddAccountPage' }); } @@ -230,29 +229,6 @@ struct AccountsPage { .height('100%') .backgroundColor($r('app.color.page_bg')) - if (this.showTypeMenu) { - Column() - .width('100%') - .height('100%') - .onClick(() => { - this.showTypeMenu = false; - }) - } - - if (this.showTypeMenu) { - Column({ space: 10 }) { - this.menuItem('日', 'CalDAV', '日历同步', TYPE_CALDAV) - this.menuItem('人', 'CardDAV', '通讯录同步', TYPE_CARDDAV) - this.menuItem('文', 'WebDAV', '文件访问', TYPE_WEBDAV) - } - .width(220) - .padding(10) - .borderRadius(16) - .backgroundColor($r('app.color.card_bg')) - .shadow({ radius: 16, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 4 }) - .margin({ right: 24, bottom: 156 }) - } - Button() { Text('+') .fontSize(26) @@ -266,7 +242,7 @@ struct AccountsPage { .shadow({ radius: 8, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 2 }) .margin(24) .onClick(() => { - this.showTypeMenu = !this.showTypeMenu; + this.openAddPage(); }) } .width('100%') @@ -285,11 +261,11 @@ struct AccountsPage { .borderRadius(20) .backgroundColor($r('app.color.card_bg')) .border({ width: 1.5, color: $r('app.color.shadow_color') }) - Text('还没有任何 DAV 账号') + Text('还没有任何 CalDAV 账号') .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor($r('app.color.text_primary')) - Text('点击右下角 + 添加 CalDAV / CardDAV / WebDAV 账号') + Text('点击右下角 + 添加 CalDAV 账号') .fontSize(13) .fontColor($r('app.color.text_secondary')) .textAlign(TextAlign.Center) @@ -298,38 +274,6 @@ struct AccountsPage { .layoutWeight(1) .justifyContent(FlexAlign.Center) } - - @Builder - menuItem(badge: string, title: string, desc: string, type: string) { - Row({ space: 12 }) { - Text(badge) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor($r('app.color.brand')) - .width(40) - .height(40) - .textAlign(TextAlign.Center) - .borderRadius(10) - .backgroundColor($r('app.color.input_bg')) - Column({ space: 2 }) { - Text(title) - .fontSize(15) - .fontWeight(FontWeight.Medium) - .fontColor($r('app.color.text_primary')) - Text(desc) - .fontSize(12) - .fontColor($r('app.color.text_secondary')) - } - .alignItems(HorizontalAlign.Start) - .layoutWeight(1) - } - .width('100%') - .padding(8) - .borderRadius(10) - .onClick(() => { - this.openAddPage(type); - }) - } } @Component diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 7d09e4d..a486ff5 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -19,6 +19,7 @@ import { IcsUtil } from '../common/IcsUtil'; import { DavClient, RemoteItem } from '../common/DavClient'; import { SystemCalendarImport } from '../common/SystemCalendarImport'; import { ScreenKeeper } from '../common/ScreenKeeper'; +import { TimelineUtil, TimelineBlock, TimelineGroup, DayTimeline, ViewRange } from '../common/TimelineUtil'; /** 月视图单元格 */ class MonthCell { @@ -37,11 +38,10 @@ struct Index { @State mode: string = 'month'; // month | week | agenda | todo @State displayYear: number = 2026; @State displayMonth: number = 0; // 0-11 - @State selectedDate: number = 0; // 当天 0 点毫秒 + @Watch('onSelectedDateChange') @State selectedDate: number = 0; // 当天 0 点毫秒 @State events: DisplayEvent[] = []; @State todos: DisplayEvent[] = []; // 待办(VTODO,单独展示) - @State agendaEvents: DisplayEvent[] = []; // 列表视图全量数据(过去1年~未来2年) - @State agendaGroupsData: AgendaGroup[] = []; // 预计算分组(避免 build 中重算卡顿) + @State agendaEvents: DisplayEvent[] = []; // 列表视图全量数据(今天 ~ 未来2年) private agendaStale: boolean = true; // 全量数据是否需要重新加载 @State monthPages: MonthCell[][] = [[], [], []]; // 上月/本月/下月 @State syncing: boolean = false; @@ -55,6 +55,9 @@ struct Index { private swiperGuard: boolean = false; private autoSyncTimer: number = -1; private nowTimer: number = -1; // 红线位置"播放"计时器(每 30s 刷新 nowMs) + private agendaScroller: Scroller = new Scroller(); // 列表视图滚动控制器:加载后定位到"当前时刻(红线)" + @State private agendaViewportH: number = 0; // 列表视口高度(实测,用于把红线滚到屏幕中间) + private agendaScrollPending: boolean = false; // 还未拿到真实视口高度 → 等实测后再校正一次 private lastSyncTime: number = 0; private permissionAsked: boolean = false; @State detailShow: boolean = false; // 只读日程详情半屏弹层 @@ -62,6 +65,9 @@ struct Index { // 服务卡片"添加"按钮深链:EntryAbility 写入,本页读取后拉起新建日程页 @Watch('onWidgetActionChange') @StorageLink('widgetAction') widgetAction: string = ''; @State nowMs: number = Date.now(); // 当前时刻毫秒(驱动红线"播放"位置,每 30s 更新) + // 时间轴视图(月/周/列表共用):按天预计算好色块布局,避免 build 中重算 + @State dayTimeline: DayTimeline = new DayTimeline(); // 选中日(月/周视图用) + @State agendaTimelines: AgendaTimelineGroup[] = []; // 列表视图:每天一条时间轴 aboutToAppear(): void { const ctx = this.getUIContext().getHostContext(); @@ -76,9 +82,19 @@ struct Index { this.rebuildPages(); this.initLandscapeListener(); this.initPermissionAndLoad(); - // 红线"播放"效果:每 30s 更新 nowMs,让当前时间红线随真实时间推进 + // 读取"默认视图"设置:打开 App 后按用户选择展示(月/周/列表,默认月) + if (ctx !== undefined) { + AppSettings.getDefaultView(ctx).then((v: string): void => { + this.mode = v; + if (v === 'agenda') { + this.ensureAgendaData(); + } + }); + } + // 红线"播放"效果:每 30s 更新 nowMs,让当前时间红线随真实时间推进(同时刷新色块"进行中"标记) this.nowTimer = setInterval((): void => { this.nowMs = Date.now(); + this.refreshNowMarks(); }, 30000); } @@ -238,8 +254,9 @@ struct Index { this.loading = true; this.agendaStale = true; // 数据可能变化,列表视图需要重新加载 const monthStart: number = new Date(this.displayYear, this.displayMonth, 1).getTime(); - const rangeStart: number = monthStart - 7 * 86400000; - const rangeEnd: number = monthStart + 32 * 86400000 + 60 * 86400000; + // 覆盖:上月尾 7 天 + 本月 + 下月,并确保选中日始终在区间内(跨月点选不漏数据) + const rangeStart: number = Math.min(monthStart - 7 * 86400000, this.selectedDate - 7 * 86400000); + const rangeEnd: number = Math.max(monthStart + 62 * 86400000, this.selectedDate + 8 * 86400000); this.events = await CalendarDataService.loadEvents(context, rangeStart, rangeEnd, this.sources); this.todos = await CalendarDataService.loadTodos(context, this.sources); LogUtil.write(`界面刷新完成:日程 ${this.events.length} 条(显示区间内),待办 ${this.todos.length} 条`); @@ -282,6 +299,37 @@ struct Index { this.buildMonthCells(this.displayYear, this.displayMonth), this.buildMonthCells(next[0], next[1]) ]; + this.rebuildDayTimeline(); + } + + /** 重建"选中日"的时间轴布局(月/周视图下方那条) */ + private rebuildDayTimeline(): void { + const dayEvents: DisplayEvent[] = this.eventsOfDate(this.selectedDate); + const tl: DayTimeline = TimelineUtil.build(dayEvents, this.selectedDate); + TimelineUtil.markNow(tl.blocks, this.nowMs); + this.dayTimeline = tl; + LogUtil.write(`时间轴(${this.fmtDateCn(this.selectedDate)}):输入 ${dayEvents.length} 条 → 色块 ${tl.blocks.length} 块,全天/跨天 ${tl.allDay.length} 条`); + } + + /** 选中日期变化(点月格/周条)→ 重算该日时间轴 */ + private onSelectedDateChange(): void { + this.rebuildDayTimeline(); + } + + /** 心跳刷新"进行中"标记(每 30s):只重算标记,不重建布局,避免整页重绘 */ + private refreshNowMarks(): void { + TimelineUtil.markNow(this.dayTimeline.blocks, this.nowMs); + for (const day of this.agendaTimelines) { + TimelineUtil.markNow(day.timeline.blocks, this.nowMs); + } + // 触发 @State 更新(TimelineBlock 非 @Observed,需整体重新赋值) + const d = new DayTimeline(); + d.dateKey = this.dayTimeline.dateKey; + d.allDay = this.dayTimeline.allDay; + d.blocks = this.dayTimeline.blocks; + d.hasTimed = this.dayTimeline.hasTimed; + this.dayTimeline = d; + this.agendaTimelines = this.agendaTimelines.slice(); } private eventsOfDate(dateMs: number): DisplayEvent[] { @@ -289,48 +337,9 @@ struct Index { this.startOfDay(e.startTime) <= dateMs && e.endTime >= dateMs); } - /** 红线日程唯一 key(与 ForEach key 保持一致) */ + /** 红线日程唯一 key(与时间轴色块 key 保持一致) */ private nowLineKey(e: DisplayEvent): string { - return `${e.isSystem ? 's' : 'l'}${e.id}_${e.startTime}`; - } - - /** 计算某日列表中"当前时间红线"位置:返回应画在上方/下方的日程 key,及正在进行的日程 key 集合。 - * 仅 isToday 为 true 时生效(红线表达的是"此刻",非今天无意义)。 */ - private computeNowLine(list: DisplayEvent[], isToday: boolean): NowLineInfo { - const res: NowLineInfo = new NowLineInfo(); - if (!isToday) { - return res; - } - const now: number = this.nowMs; - const timed: DisplayEvent[] = list - .filter((e: DisplayEvent): boolean => !e.isAllDay && !this.spansDays(e)) - .sort((a: DisplayEvent, b: DisplayEvent): number => a.startTime - b.startTime); - // 1) 正在进行的(start<=now now) { - res.aboveKey = this.nowLineKey(e); - return res; - } - } - // 3) 全部已结束 → 红线画在最后一条下方 - if (timed.length > 0) { - res.belowKey = this.nowLineKey(timed[timed.length - 1]); - } - return res; + return TimelineUtil.keyOf(e); } private switchMonth(delta: number): void { @@ -1064,6 +1073,7 @@ struct Index { this.mode = key; if (key === 'agenda') { await this.ensureAgendaData(); + this.scrollAgendaToNow(); } } @@ -1077,21 +1087,96 @@ struct Index { } this.loading = true; const now: number = Date.now(); - const start: number = this.startOfDay(now); + const today: number = this.startOfDay(now); + // 只从"今天"开始显示:不再回看过去的日期 + const start: number = today; const end: number = now + 730 * 86400000; try { const raw: DisplayEvent[] = await CalendarDataService.loadEvents(context, start, end, this.sources); - // 保留"今天 0 点以来"的全部日程:今天已结束的也显示(否则重复日程的第一次发生会被隐藏), - // 昨天及更早且已结束的不显示;跨天进行中的归到今天,与卡片一致 + // 保留:今天还没结束的(含进行中)+ 未来全部 this.agendaEvents = raw.filter((e: DisplayEvent): boolean => e.endTime >= start); - this.agendaGroupsData = this.buildAgendaGroups(this.agendaEvents); + this.agendaTimelines = this.buildAgendaTimelines(this.agendaEvents); this.agendaStale = false; - LogUtil.write(`列表视图加载:${this.agendaEvents.length} 条(今天~未来2年),分组 ${this.agendaGroupsData.length} 组`); + LogUtil.write(`列表视图加载:${this.agendaEvents.length} 条(今天~未来2年),时间轴 ${this.agendaTimelines.length} 天`); } catch (err) { const e = err as BusinessError; LogUtil.write(`列表视图加载失败:${e.message}`); } this.loading = false; + this.scrollAgendaToNow(); + } + + /** 列表视图加载完成后定位到**今天当前时刻(红线)**,并让红线大致落在屏幕中间。 + * 不再用 scrollToIndex(那只会对齐到卡片顶部,看到的是最早那条日程)。 + * 做法:累加今天之前各天卡片高度(用与渲染一致的公式算),再加上红线在"今天"卡片内的偏移, + * 最后减半屏高度 → 红线居中。 */ + private scrollAgendaToNow(): void { + if (this.mode !== 'agenda') { + return; + } + this.agendaScrollPending = true; + setTimeout((): void => { + this.doAgendaScroll(); + }, 80); + } + + /** 真正执行滚动:视口高度还没实测到时先用估算值滚一次,等 onAreaChange 拿到真实高度再校正 */ + private doAgendaScroll(): void { + if (!this.agendaScrollPending) { + return; + } + const idx: number = this.agendaTimelines.findIndex((d: AgendaTimelineGroup): boolean => d.isToday); + if (idx < 0) { + // 今天没有日程:直接回到列表顶部 + this.agendaScroller.scrollToIndex(0); + this.agendaScrollPending = false; + return; + } + // List 顶部 padding 6 + 每项之间 space 10 + let cardTop: number = 6; + for (let i = 0; i < idx; i++) { + cardTop = cardTop + this.agendaCardH(this.agendaTimelines[i]) + 10; + } + const d: AgendaTimelineGroup = this.agendaTimelines[idx]; + const vp: number = this.agendaViewportH > 0 ? this.agendaViewportH : 600; + // 今天定时日程已全部结束 → 红线不显示,直接定位到今天卡片顶部(不再按"当前时刻"居中) + let target: number = cardTop - 6; + if (!TimelineUtil.allTimedEnded(d.timeline.blocks, this.nowMs)) { + // 卡片内:padding 10 + 日期头 + space 6 + 全天区 + 红线在视窗内的偏移 + let y: number = cardTop + 10 + this.agendaHeaderH() + 6 + this.agendaAllDayH(d.timeline); + const rg: ViewRange = TimelineUtil.viewRange(d.timeline.blocks, true, this.nowMs); + y = y + (TimelineUtil.nowRatio(this.nowMs) - rg.startHour / 24) * TimelineUtil.HOUR_UNIT * 24; + target = y - vp / 2; + } + this.agendaScroller.scrollTo({ xOffset: 0, yOffset: target < 0 ? 0 : target }); + // 只有拿到真实视口高度后才算完成;否则等 onAreaChange 再校正一次 + if (this.agendaViewportH > 0) { + this.agendaScrollPending = false; + } + } + + /** 列表某天卡片的总高度(必须与 agendaBody 里的实际布局保持一致) */ + private agendaCardH(d: AgendaTimelineGroup): number { + // 上下 padding 10 + 日期头 + Column space 6 + 时间轴(全天区 + 视窗高);border 画在组件内不占高 + return 10 + this.agendaHeaderH() + 6 + this.agendaTimelineH(d) + 10; + } + /** 日期头高度:padding top 4 + 14 号文字行高约 20 */ + private agendaHeaderH(): number { + return 24; + } + /** 全天区高度:每条 26 + 间隔 4 + 底部 padding 6 */ + private agendaAllDayH(tl: DayTimeline): number { + if (tl.allDay.length === 0) { + return 0; + } + return tl.allDay.length * 26 + (tl.allDay.length - 1) * 4 + 6; + } + /** 某天时间轴高度 = 全天区 + 视窗小时数 × 每小时 vp + * (视窗的"是否并入当前小时"必须与 DayTimelineView 完全一致,否则卡片高度算错) */ + private agendaTimelineH(d: AgendaTimelineGroup): number { + const incNow: boolean = d.isToday && !TimelineUtil.allTimedEnded(d.timeline.blocks, this.nowMs); + const rg: ViewRange = TimelineUtil.viewRange(d.timeline.blocks, incNow, this.nowMs); + return this.agendaAllDayH(d.timeline) + (rg.endHour - rg.startHour) * TimelineUtil.HOUR_UNIT; } /** 周条:周一到周日(周视图顶部 / 复用) */ @@ -1574,48 +1659,35 @@ struct Index { .layoutWeight(1) } - /** 单日日程列表(周视图使用) */ + /** 单日竖向时间轴滚动区(周视图 / 月视图下方共用);DayTimelineView 自身带 Scroll 并自动定位到当前时刻 */ @Builder eventList() { - Scroll() { - Column({ space: 8 }) { - ForEach(this.eventsOfDate(this.selectedDate), (e: DisplayEvent) => { - Column() { - if (this.computeNowLine(this.eventsOfDate(this.selectedDate), - this.selectedDate === this.startOfDay(this.nowMs)).aboveKey === this.nowLineKey(e)) { - NowLineMarker() - } - this.eventRow(e, this.computeNowLine(this.eventsOfDate(this.selectedDate), - this.selectedDate === this.startOfDay(this.nowMs)).nowKeys.has(this.nowLineKey(e))) - if (this.computeNowLine(this.eventsOfDate(this.selectedDate), - this.selectedDate === this.startOfDay(this.nowMs)).belowKey === this.nowLineKey(e)) { - NowLineMarker() - } - } + Column() { + DayTimelineView({ + timeline: this.dayTimeline, + nowMs: this.nowMs, + showNowLine: this.selectedDate === this.startOfDay(this.nowMs), + scrollable: true, + onPick: (e: DisplayEvent): void => this.openEvent(e) + }) + if (!this.dayTimeline.hasTimed && this.dayTimeline.allDay.length === 0 && !this.loading) { + Text('当天没有日程') + .fontSize(13) + .fontColor($r('app.color.text_hint')) .width('100%') - }, (e: DisplayEvent) => `${e.isSystem ? 's' : 'l'}${e.id}_${e.startTime}`) - if (this.eventsOfDate(this.selectedDate).length === 0 && !this.loading) { - Text('当天没有日程') - .fontSize(13) - .fontColor($r('app.color.text_hint')) - .width('100%') - .textAlign(TextAlign.Center) - .padding(20) - } + .textAlign(TextAlign.Center) + .padding(20) } - .width('100%') - .padding({ left: 20, right: 20, top: 4, bottom: 24 }) - .constraintSize({ minHeight: '100%' }) } .layoutWeight(1) - .scrollBar(BarState.Off) - .edgeEffect(EdgeEffect.Spring) - .align(Alignment.Top) + .width('100%') + .padding({ left: 12, right: 16, top: 4, bottom: 24 }) + .alignItems(HorizontalAlign.Start) } @Builder agendaBody() { - List({ space: 6 }) { + List({ space: 10, scroller: this.agendaScroller }) { ListItem() { Row({ space: 8 }) { if (this.loading) { @@ -1624,7 +1696,8 @@ struct Index { .height(24) .color($r('app.color.brand')) } - Text(this.loading ? '正在加载全部日程…' : `共 ${this.agendaEvents.length} 条`) + Text(this.loading ? '正在加载全部日程…' : `共 ${this.agendaEvents.length} 条` + + `,${this.agendaTimelines.length} 天`) .fontSize(12) .fontColor($r('app.color.text_hint')) } @@ -1632,77 +1705,115 @@ struct Index { .padding({ top: 2, bottom: 2 }) } - ForEach(this.agendaGroupsData, (group: AgendaGroup) => { + // 每一天:日期头 + 该日一条竖向时间轴(0-23 格,色块按时间平铺,冲突平分宽度) + ForEach(this.agendaTimelines, (day: AgendaTimelineGroup) => { ListItem() { - Text(group.label) - .fontSize(13) - .fontWeight(FontWeight.Bold) - .fontColor($r('app.color.text_secondary')) - .width('100%') - .padding({ top: 6, bottom: 2 }) - } - ForEach(group.items, (e: DisplayEvent) => { - ListItem() { - Column() { - if (this.computeNowLine(group.items, group.label === '今天').aboveKey === this.nowLineKey(e)) { - NowLineMarker() - } - this.eventRow(e, this.computeNowLine(group.items, group.label === '今天') - .nowKeys.has(this.nowLineKey(e))) - if (this.computeNowLine(group.items, group.label === '今天').belowKey === this.nowLineKey(e)) { - NowLineMarker() + Column({ space: 6 }) { + Row({ space: 8 }) { + Text(day.label) + .fontSize(14) + .fontWeight(FontWeight.Bold) + .fontColor(day.isToday ? $r('app.color.brand') : $r('app.color.text_primary')) + if (day.isToday) { + Text('今天') + .fontSize(10) + .fontColor($r('app.color.button_text')) + .backgroundColor($r('app.color.brand')) + .borderRadius(6) + .padding({ left: 6, right: 6, top: 1, bottom: 1 }) } + Blank() + Text(`${day.timeline.blocks.length + day.timeline.allDay.length} 条`) + .fontSize(11) + .fontColor($r('app.color.text_hint')) } .width('100%') + .padding({ top: 4 }) + + DayTimelineView({ + timeline: day.timeline, + nowMs: this.nowMs, + showNowLine: day.isToday, + scrollable: false, + onPick: (e: DisplayEvent): void => this.openEvent(e) + }) } - }, (e: DisplayEvent) => `${e.isSystem ? 's' : 'l'}${e.id}_${e.startTime}_${e.title}`) - }, (group: AgendaGroup) => group.label) + .width('100%') + .padding(10) + .borderRadius(12) + .backgroundColor($r('app.color.card_bg')) + .border({ width: 1, color: $r('app.color.shadow_color') }) + } + }, (day: AgendaTimelineGroup) => `day_${day.dateMs}`) } .width('100%') .layoutWeight(1) .scrollBar(BarState.Auto) .edgeEffect(EdgeEffect.Spring) - .padding({ left: 20, right: 20, top: 6, bottom: 24 }) - .cachedCount(8) + .padding({ left: 12, right: 12, top: 6, bottom: 24 }) + .cachedCount(4) + .onAreaChange((oldVal: Area, newVal: Area): void => { + const h: number = Number(newVal.height); + if (h > 0 && (h - this.agendaViewportH > 1 || h - this.agendaViewportH < -1)) { + this.agendaViewportH = h; + if (this.agendaScrollPending) { + this.doAgendaScroll(); + } + } + }) } - /** 列表视图分组:今天 ~ 未来的日程(跨天进行中的归今天,与卡片一致)。 - * 结果预计算到 @State,避免每次 build 重算导致卡顿 */ - private buildAgendaGroups(events: DisplayEvent[]): AgendaGroup[] { - const map: Map = new Map(); + /** 列表视图:把每天的分组转成"一天一条竖向时间轴" + * 多日/跨天日程会**展开到它覆盖的每一天**(起始日记为跨天,中间日整天), + * 确保列表里每天的数据完整,而不是只挂在第一天。 */ + private buildAgendaTimelines(events: DisplayEvent[]): AgendaTimelineGroup[] { const today: number = this.startOfDay(Date.now()); - for (const e of events) { - const spans: boolean = this.startOfDay(e.endTime) > this.startOfDay(e.startTime); - // 跨天且已开始:归今天;其余归开始日 - const key: number = spans && this.startOfDay(e.startTime) < today - ? today : this.startOfDay(e.startTime); + const map: Map = new Map(); + const push = (key: number, e: DisplayEvent): void => { + // 列表视图只显示"今天及以后":跨天日程展开时也不往前补日期 + if (key < today) { + return; + } let arr: DisplayEvent[] | undefined = map.get(key); if (arr === undefined) { arr = []; map.set(key, arr); } arr.push(e); + }; + for (const e of events) { + const sDay: number = this.startOfDay(e.startTime); + let eDay: number = this.startOfDay(e.endTime); + // 结束时刻恰好在 0 点 → 实际只到前一天(对齐常见日历约定) + if (e.endTime <= eDay && eDay > sDay) { + eDay = eDay - 86400000; + } + const spans: boolean = eDay > sDay; + if (!spans) { + push(sDay, e); + continue; + } + // 跨多天:逐天挂上(中间日同样展示,避免"这天什么都没有") + const days: number = Math.round((eDay - sDay) / 86400000); + const maxDays: number = 60; // 防御:异常超长日程最多展开 60 天 + const n: number = days > maxDays ? maxDays : days; + for (let i = 0; i <= n; i++) { + push(sDay + i * 86400000, e); + } } const keys: number[] = Array.from(map.keys()).sort((a: number, b: number): number => a - b); - const groups: AgendaGroup[] = []; + const out: AgendaTimelineGroup[] = []; for (const key of keys) { - const items: DisplayEvent[] = map.get(key) ?? []; - items.sort((a: DisplayEvent, b: DisplayEvent): number => { - const aAll: boolean = a.isAllDay || - this.startOfDay(a.endTime) > this.startOfDay(a.startTime); - const bAll: boolean = b.isAllDay || - this.startOfDay(b.endTime) > this.startOfDay(b.startTime); - if (aAll !== bAll) { - return aAll ? -1 : 1; - } - return a.startTime - b.startTime; - }); - const g = new AgendaGroup(); + const g = new AgendaTimelineGroup(); + g.dateMs = key; g.label = key === today ? '今天' : this.fmtDateCn(key); - g.items = items; - groups.push(g); + g.isToday = key === today; + const tl: DayTimeline = TimelineUtil.build(map.get(key) ?? [], key); + TimelineUtil.markNow(tl.blocks, this.nowMs); + g.timeline = tl; + out.push(g); } - return groups; + return out; } // ---------- 待办(VTODO) ---------- @@ -1819,88 +1930,6 @@ struct Index { return this.startOfDay(e.endTime) > this.startOfDay(e.startTime); } - @Builder - eventRow(e: DisplayEvent, isNow: boolean = false) { - Row({ space: 10 }) { - Column() - .width(4) - .height(38) - .borderRadius(2) - .backgroundColor(isNow ? '#FF3B30' : e.color) - Column({ space: 3 }) { - Text(e.title === '' ? '(无标题)' : e.title) - .fontSize(15) - .fontColor(isNow ? '#FF3B30' : $r('app.color.text_primary')) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Row({ space: 6 }) { - Text(e.isAllDay || this.spansDays(e) - ? '全天' : `${this.fmtTime(e.startTime)} - ${this.fmtTime(e.endTime)}`) - .fontSize(12) - .fontColor(isNow ? '#FF3B30' : $r('app.color.text_secondary')) - if (e.recurring) { - Text('↻ 重复') - .fontSize(10) - .fontColor($r('app.color.text_hint')) - .padding({ left: 4, right: 4, top: 1, bottom: 1 }) - .borderRadius(4) - .backgroundColor($r('app.color.chip_off_bg')) - } - if (isNow) { - Text('● 进行中') - .fontSize(10) - .fontColor('#FF3B30') - .padding({ left: 4, right: 4, top: 1, bottom: 1 }) - .borderRadius(4) - .backgroundColor('#FFECEA') - } - } - if (e.location !== '') { - Row({ space: 4 }) { - Text('📍') - .fontSize(11) - .fontColor($r('app.color.text_hint')) - Text(e.location) - .fontSize(12) - .fontColor($r('app.color.text_hint')) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .width('100%') - } - } - .alignItems(HorizontalAlign.Start) - .layoutWeight(1) - if (e.isSystem) { - Text('系统') - .fontSize(10) - .fontColor($r('app.color.text_hint')) - .padding({ left: 6, right: 6, top: 2, bottom: 2 }) - .borderRadius(6) - .backgroundColor($r('app.color.chip_off_bg')) - } - // 所属日历本:最右侧、垂直居中,颜色同日历本;只读日历本加删除线标识 - if (e.calName !== '') { - Text(e.calName) - .fontSize(11) - .fontColor(e.color) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .constraintSize({ maxWidth: '30%' }) - .decoration({ type: e.writable ? TextDecorationType.None : TextDecorationType.LineThrough }) - } - } - .alignItems(VerticalAlign.Center) - .width('100%') - .padding(10) - .borderRadius(12) - .backgroundColor($r('app.color.card_bg')) - .border({ width: 1, color: $r('app.color.shadow_color') }) - .onClick(() => { - this.openEvent(e); - }) - } - @Builder dayCell(cell: MonthCell) { Column({ space: 2 }) { @@ -1952,56 +1981,443 @@ struct Index { } } -/** 日程列表的按日分组 */ -class AgendaGroup { +/** 列表视图的一天:日期标签 + 该日的时间轴布局 */ +class AgendaTimelineGroup { + dateMs: number = 0; label: string = ''; - items: DisplayEvent[] = []; + isToday: boolean = false; + timeline: DayTimeline = new DayTimeline(); } -/** 某日列表中"当前时间红线"位置信息(避免内联对象字面量类型,ArkTS 不允许) */ -class NowLineInfo { - aboveKey: string = ''; // 红线画在该 key 对应日程的上方 - belowKey: string = ''; // 今天所有有时间日程已结束:红线画在该 key 对应日程的下方 - nowKeys: Set = new Set(); // 正在进行的日程 key 集合 +/** 日程层纵向序列中的一行:空隙 / 冲突组。整点线与红线已移到另外两层,本层不含任何"线"。 + * topRatio~botRatio = 该行覆盖的当日比例区间(0~1,乘整日高得 vp)。 */ +class TimeRow { + kind: number = 0; // 0=空隙 1=冲突组行 + h: number = 0; // 备用(行高,当前未使用) + topRatio: number = 0; + botRatio: number = 0; + group: TimelineGroup = new TimelineGroup(); // kind=1 时有效 + key: string = ''; } -/** 当前时间红线标记(带呼吸/播放效果):左侧红点 + 贯穿整行的红色细线。 - * 自带动画(opacity 往复),不依赖外部状态刷新,避免整页重绘。 */ +/** 单日竖向时间轴:左侧 0-23 刻度,右侧按"冲突组"分行、组内每条日程占一列(lane)按时间纵向定位。 + * 每条日程 = 一个独立色块(跨整点也不再拆分、名字只出现一次);冲突日程左右并排、不重叠。 + * scrollable=true 时自带 Scroll+Scroller,并在"今天"自动定位到当前时刻(eventList 月/周视图用); + * scrollable=false 时整页平铺(列表视图的今天卡片用,避免 Scroll 嵌套进 List)。 */ @Component -struct NowLineMarker { - @State private dim: boolean = false; - private timer: number = -1; +struct DayTimelineView { + @Prop timeline: DayTimeline; + @Prop nowMs: number; // 当前时刻(drawLine 时定位红线) + @Watch('onShowNowLine') @Prop showNowLine: boolean = false; // 仅"今天"显示红线 + 触发自动定位 + @Prop scrollable: boolean = false; // 是否自带可滚动容器(月/周视图日时间轴=true;列表视图卡片=false) + hourUnit: number = TimelineUtil.HOUR_UNIT; + onPick: (e: DisplayEvent) => void = (e: DisplayEvent): void => {}; + private scroller: Scroller = new Scroller(); + @State private viewportH: number = 0; // 滚动视口高度(由 Scroll 实测,用于把"当前时刻"居中) + @State private allDayH: number = 0; // 顶部"全天/跨天"区高度(它现在随内容滚动,居中定位要加上) + private pendingScroll: boolean = false; aboutToAppear(): void { - // 每 ~1.1s 切换一次,配合 .animation 形成呼吸式"播放"效果 - this.timer = setInterval((): void => { - this.dim = !this.dim; - }, 1100); + this.scrollToNow(); } - aboutToDisappear(): void { - if (this.timer !== -1) { - clearInterval(this.timer); - this.timer = -1; + /** 显示日变为"今天"(或从今天切走)时触发;切到今天则自动定位到当前时刻 */ + private onShowNowLine(): void { + this.scrollToNow(); + } + + /** 自动滚动:把"当前时刻红线"定位到可视区**垂直居中** + * (目标 y = nowRatio * 整日高 − 视口高/2,夹取 ≥0)。仅今天且自带滚动时生效。 + * 首帧视口高度未知 → 先用估算值滚一次,等 onAreaChange 实测到高度后再校正一次。 */ + private scrollToNow(): void { + // 只有"红线会显示"时才需要自动定位到当前时刻;日程已全部结束时视窗已截断,不再滚动 + if (!this.scrollable || !this.includeNowHour()) { + return; } + this.pendingScroll = true; + this.tryScroll(); + } + private tryScroll(): void { + if (!this.pendingScroll) { + return; + } + const vp: number = this.viewportH > 0 ? this.viewportH : this.hourUnit * 8; + // 内容顶部还有"全天/跨天"区(allDayH),且时间轴只从 viewTop 开始 → 都要计入偏移 + const y: number = this.allDayH + (TimelineUtil.nowRatio(this.nowMs) - this.viewTop()) * this.totalH() - vp / 2; + setTimeout((): void => { + this.scroller.scrollTo({ xOffset: 0, yOffset: y < 0 ? 0 : y }); + }, 60); + if (this.viewportH > 0) { + this.pendingScroll = false; + } + } + + private hourText(h: number): string { + return h < 10 ? `0${h}:00` : `${h}:00`; + } + + /** 是否把"当前小时"并入视窗: + * 仅当"今天 + 还有未结束的定时日程"(即红线会显示)时才需要,保证红线可见。 + * 若今天定时日程已全部结束(红线已隐藏),则**不再并入当前小时** → 时间刻度直接截断到最晚日程。 */ + private includeNowHour(): boolean { + return this.showNowLine && !this.allTimedEnded(); + } + /** 视窗起始小时(统一走 TimelineUtil.viewRange,与列表视图高度计算共用同一份逻辑) */ + private viewStartHour(): number { + return TimelineUtil.viewRange(this.timeline.blocks, this.includeNowHour(), this.nowMs).startHour; + } + /** 视窗结束小时 */ + private viewEndHour(): number { + return TimelineUtil.viewRange(this.timeline.blocks, this.includeNowHour(), this.nowMs).endHour; + } + /** 视窗上/下边界(当日比例 0~1) */ + private viewTop(): number { + return this.viewStartHour() / 24; + } + private viewBot(): number { + return this.viewEndHour() / 24; + } + /** 视窗总高(vp):三层堆叠区与左侧刻度都按这个高度对齐 */ + private viewH(): number { + return (this.viewEndHour() - this.viewStartHour()) * this.hourUnit; + } + /** 当前选中日:定时日程是否已全部结束(统一走 TimelineUtil,供"隐藏红线/截断视窗"共用) */ + private allTimedEnded(): boolean { + return TimelineUtil.allTimedEnded(this.timeline.blocks, this.nowMs); + } + /** 当前时间红线是否显示(今天 + 今天还有未结束的定时日程 + 当前时刻落在视窗内) */ + private nowVisible(): boolean { + const r: number = TimelineUtil.nowRatio(this.nowMs); + return this.showNowLine && !this.allTimedEnded() + && r >= this.viewTop() - 0.0001 && r <= this.viewBot() + 0.0001; + } + /** 红线距层顶的偏移(vp):减 1vp 让 2vp 高的线正好压在"现在"这一刻 */ + private nowOffsetVp(): number { + const y: number = (TimelineUtil.nowRatio(this.nowMs) - this.viewTop()) * this.totalH() - 1; + return y < 0 ? 0 : y; + } + /** 左侧小时刻度:只渲染视窗内的小时 */ + private hourLabels(): number[] { + const arr: number[] = []; + for (let h = this.viewStartHour(); h < this.viewEndHour(); h++) { + arr.push(h); + } + return arr; + } + + // ---- 布局换算:整日 = 24 小时竖向;横向按"冲突组"分列(lane),组内每条日程占一列 ---- + /** 整日高度(vp) */ + private totalH(): number { + return this.hourUnit * 24; + } + /** 由色块重建冲突分组(组内按列并排) */ + private groups(): TimelineGroup[] { + return TimelineUtil.groupsOf(this.timeline.blocks); + } + /** 冲突组这一行的高度(vp)= 该组的时间跨度 */ + private bandH(g: TimelineGroup): number { + return (g.endRatio - g.startRatio) * this.totalH(); + } + /** 某一列(lane)中、与本行窗口 [topRatio,botRatio] 有交集的色块(窗口外的整块跳过) */ + private laneBlocks(r: TimeRow, lane: TimelineBlock[]): TimelineBlock[] { + const out: TimelineBlock[] = []; + for (const b of lane) { + const be: number = b.topRatio + b.heightRatio; + if (be > r.topRatio + 0.0001 && b.topRatio < r.botRatio - 0.0001) { + out.push(b); + } + } + return out; + } + /** 列内第 index 个色块之前需要空出的高度(vp)= 本块顶 − 上一块底(都夹紧在窗口内,≥0) */ + private lanePadH(r: TimeRow, lane: TimelineBlock[], index: number): number { + const arr: TimelineBlock[] = this.laneBlocks(r, lane); + let prevEnd: number = r.topRatio; + if (index > 0) { + const p: TimelineBlock = arr[index - 1]; + const pe: number = p.topRatio + p.heightRatio; + prevEnd = pe > r.topRatio ? pe : r.topRatio; + } + const b: TimelineBlock = arr[index]; + const top: number = b.topRatio > r.topRatio ? b.topRatio : r.topRatio; + const d: number = (top - prevEnd) * this.totalH(); + return d < 0 ? 0 : d; + } + /** 色块高度(vp):按实际时长换算,至少 14vp 保证短日程也可见(超出组行的部分由列裁切) */ + private blockH(b: TimelineBlock): number { + const h: number = b.heightRatio * this.totalH(); + return h < 14 ? 14 : h; + } + // 整点灰线由第 1 层(网格层)画、红线由第 3 层画 → 日程层不再为它们切段。 + /** 左侧刻度:当前小时是否高亮(仅今天) */ + private isNowHour(h: number): boolean { + return this.showNowLine && Math.floor(TimelineUtil.nowRatio(this.nowMs) * 24) === h; + } + /** 组装"日程层"的纵向序列:只渲染视窗 [viewTop,viewBot] 内的时间。 + * **本层只有两样东西:空隙(Blank)与冲突组行**,不再为整点线 / 红线切段 —— + * 线与网格都交给另外两层去画,所以每条日程永远是一个完整、连续的色块。 + * 注意:所有 key 都带 dateKey + 视窗 + 内容签名 → 切换日期/内容时 key 必变,强制重渲染 + * (否则 ArkUI 复用旧子组件会导致"串天/重复")。 */ + private buildTimeRows(): TimeRow[] { + const rows: TimeRow[] = []; + const gs: TimelineGroup[] = this.groups(); + const top: number = this.viewTop(); + const bot: number = this.viewBot(); + const dk: string = `${this.timeline.dateKey}_${this.viewStartHour()}_${this.viewEndHour()}`; + let cursor: number = top; + for (const g of gs) { + const gsx: number = g.startRatio > top ? g.startRatio : top; + const gex: number = g.endRatio < bot ? g.endRatio : bot; + if (gex - gsx <= 0.0002) { + continue; // 整组都在视窗外 + } + this.pushSpanRow(rows, cursor, gsx, null, dk); + this.pushSpanRow(rows, gsx, gex, g, dk); + cursor = gex; + } + this.pushSpanRow(rows, cursor, bot, null, dk); + return rows; + } + + private pushSpanRow(rows: TimeRow[], from: number, to: number, g: TimelineGroup | null, + dk: string): void { + if (to - from <= 0.0002) { + return; + } + const r = new TimeRow(); + r.topRatio = from; + r.botRatio = to; + if (g === null) { + r.kind = 0; + r.key = `g_${dk}_${rows.length}_${Math.round((to - from) * 100000)}`; + } else { + r.kind = 1; + r.group = g; + let sig: string = ''; + for (const b of g.blocks) { + sig = sig + '_' + b.eventKey; + } + r.key = `b_${dk}_${rows.length}${sig}`; + } + rows.push(r); } build() { - Row() { - Column() - .width(6) - .height(6) - .borderRadius(3) - .backgroundColor('#FF3B30') - Column() - .height(2) + Column() { + // 时间轴主体(含顶部"全天/跨天"条 —— 它们随内容一起滚动,向下滑时跟 0 点一起上移)。 + // scrollable=true 时包一层 Scroll 并自动定位到当前时刻;否则整页平铺。 + if (this.scrollable) { + Scroll(this.scroller) { + this.timelineBody() + } .layoutWeight(1) - .backgroundColor('#FF3B30') - .borderRadius(1) + .scrollBar(BarState.Off) + .edgeEffect(EdgeEffect.Spring) + .align(Alignment.Top) + .onAreaChange((oldVal: Area, newVal: Area): void => { + const h: number = Number(newVal.height); + if (h > 0 && (this.viewportH < 1 || h - this.viewportH > 1 || h - this.viewportH < -1)) { + this.viewportH = h; + this.tryScroll(); + } + }) + } else { + this.timelineBody() + } } .width('100%') - .padding({ top: 3, bottom: 3 }) - .opacity(this.dim ? 0.45 : 1) - .animation({ duration: 1000 }) + // 自带滚动(月/周视图):占满父容器剩余高度;平铺(列表视图):按内容自适应高度 + // —— 列表里每天需要的高度不同(视窗已按日程起止截断),必须让外层随内容伸缩,否则会显示不全 + .layoutWeight(this.scrollable ? 1 : 0) + .alignItems(HorizontalAlign.Start) + } + + /** 时间轴主体:顶部"全天/跨天"条(随滚动一起移动)+ 左侧小时刻度 + 右侧按冲突组分行、组内按列并排 */ + @Builder + private timelineBody() { + Column() { + // 全天 / 跨天日程:同样用**色块**展示(与普通日程保持一致的视觉), + // 画在 0 点之前并**随内容一起滚动**(向下滑时跟 0 点一起上移,不再常驻顶部) + if (this.timeline.allDay.length > 0) { + Column({ space: 4 }) { + ForEach(this.timeline.allDay, (e: DisplayEvent) => { + Row({ space: 6 }) { + Text(e.isAllDay ? '全天' : '跨天') + .fontSize(9) + .fontColor('#FFFFFF') + .backgroundColor('#26000000') + .borderRadius(6) + .padding({ left: 5, right: 5, top: 1, bottom: 1 }) + Text(e.title === '' ? '(无标题)' : e.title) + .fontSize(12) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + } + .alignItems(VerticalAlign.Center) + .width('100%') + .height(26) + .padding({ left: 8, right: 8 }) + .borderRadius(8) + .backgroundColor(e.color) + .onClick(() => this.onPick(e)) + }, (e: DisplayEvent) => `al_${this.timeline.dateKey}_${TimelineUtil.keyOf(e)}`) + } + .width('100%') + .padding({ bottom: 6 }) + .onAreaChange((oldVal: Area, newVal: Area): void => { + const h: number = Number(newVal.height); + if (h > 0 && (h - this.allDayH > 1 || h - this.allDayH < -1)) { + this.allDayH = h; + this.tryScroll(); + } + }) + } + + Row() { + // 左侧小时刻度(每格一条底线,作为时间尺;当前小时高亮)。只渲染视窗内的小时 + Column() { + ForEach(this.hourLabels(), (h: number) => { + Text(this.hourText(h)) + .fontSize(10) + .fontColor(this.isNowHour(h) ? '#FF3B30' : '#9AA0A6') + .width(36) + .height(this.hourUnit) + .textAlign(TextAlign.End) + .padding({ right: 4, top: 2 }) + .border({ width: { bottom: 0.5 }, color: { bottom: '#14000000' } }) + }, (h: number) => `h${h}`) + } + .width(36) + + // 右侧内容区:**三层堆叠**(网格层 / 日程层 / 红线层)。 + // 三层尺寸、分格方式完全一致;上层透明,只画自己该画的东西,互不挤占 → + // 日程色块再也不会被整点线或红线"切开",红线也永远浮在最上面。 + Stack() { + this.gridLayer() // 第 1 层:整点网格线(贯穿全宽) + this.eventLayer() // 第 2 层:日程色块(透明底,盖住下面的网格线属正常) + this.nowLayer() // 第 3 层:当前时间红线(盖住前两层) + } + .layoutWeight(1) + .height(this.viewH()) + .alignContent(Alignment.TopStart) + .border({ width: { left: 0.5 }, color: { left: '#14000000' } }) + } + .width('100%') + .alignItems(VerticalAlign.Top) + } + .width('100%') + } + + // ===== 三层绘图:每层尺寸与分格方式完全相同(高 = viewH,宽 = 内容区全宽),上层透明 ===== + + /** 第 1 层:整点网格 —— 每个小时一格,格底一条灰线,贯穿全宽 */ + @Builder + private gridLayer() { + Column() { + ForEach(this.hourLabels(), (h: number) => { + Column() + .width('100%') + .height(this.hourUnit) + .border({ width: { bottom: 0.5 }, color: { bottom: '#14000000' } }) + .hitTestBehavior(HitTestMode.None) // 装饰层子节点同样不参与命中测试 + }, (h: number) => `gd_${h}`) + } + .width('100%') + .height('100%') + .hitTestBehavior(HitTestMode.None) // 装饰层:不参与命中测试,触碰事件穿透到下层色块 + } + + /** 第 2 层:日程色块 —— 冲突组分行 + 组内 lane 分列,纵向用 Blank 定位。 + * 本层不再需要为"整点线 / 红线"让路而切段 → 每条日程永远是一个完整连续的色块。 */ + @Builder + private eventLayer() { + Column() { + ForEach(this.buildTimeRows(), (r: TimeRow) => { + if (r.kind === 1) { + Row() { + ForEach(r.group.lanes, (lane: TimelineBlock[], li: number) => { + Column() { + ForEach(this.laneBlocks(r, lane), (b: TimelineBlock, index: number) => { + Blank().height(this.lanePadH(r, lane, index)) + Column({ space: 1 }) { + Text(b.title) + .fontSize(11) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + if (b.heightRatio * 86400000 >= 40 * 60000) { + Text(b.timeText) + .fontSize(9) + .fontColor('#E6FFFFFF') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + } + } + .alignItems(HorizontalAlign.Start) + .justifyContent(FlexAlign.Start) + .padding({ left: 6, right: 3, top: 2, bottom: 2 }) + .borderRadius(6) + .backgroundColor(b.color) + .opacity(b.isNow ? 1 : 0.92) + .clip(true) + .width('100%') + .height(this.blockH(b)) + .onClick(() => { + if (b.ev !== null) { + this.onPick(b.ev); + } + }) + }, (b: TimelineBlock) => `blk_${r.key}_${b.eventKey}`) + } + .layoutWeight(1) + .height('100%') + .padding({ right: 2 }) + .clip(true) + }, (lane: TimelineBlock[], li: number) => `ln_${r.key}_${li}`) + } + .width('100%') + .height((r.botRatio - r.topRatio) * this.totalH()) + .alignItems(VerticalAlign.Top) + } else { + // 空隙:纯占位(网格线在第 1 层,红线在第 3 层,本层不参与) + Blank().height((r.botRatio - r.topRatio) * this.totalH()) + } + }, (r: TimeRow) => r.key) + } + .width('100%') + .height('100%') + } + + /** 第 3 层:当前时间红线 —— 浮在最上层,不挤占任何布局,也不切割任何色块 */ + @Builder + private nowLayer() { + Column() { + if (this.nowVisible()) { + Blank().height(this.nowOffsetVp()).hitTestBehavior(HitTestMode.None) // 占位块:不拦截点击 + Row() { + Column() + .width(6) + .height(6) + .borderRadius(3) + .backgroundColor('#FF3B30') + Column() + .height(2) + .layoutWeight(1) + .backgroundColor('#FF3B30') + .borderRadius(1) + } + .width('100%') + .hitTestBehavior(HitTestMode.None) // 红线本身也不拦截点击,穿透到色块层 + } + } + .width('100%') + .height('100%') + .hitTestBehavior(HitTestMode.None) // 装饰层:整层不参与命中测试,触碰事件穿透到下层色块 } } \ No newline at end of file diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index fcd971a..7f7526a 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -32,6 +32,7 @@ struct SettingsPage { @State notifyEnabled: boolean = true; // 通知权限状态(提醒依赖) @State showFeatures: boolean = false; // 软件特性弹层 @State showHelp: boolean = false; // 使用帮助弹层 + @State defaultView: string = 'month'; // 打开 App 默认视图:month | week | agenda private context?: common.Context; aboutToAppear(): void { @@ -58,6 +59,9 @@ struct SettingsPage { AppSettings.getMutedReminderKeys(ctx).then((v: string[]): void => { this.mutedKeys = v; }); + AppSettings.getDefaultView(ctx).then((v: string): void => { + this.defaultView = v; + }); this.refreshNotifyState(); this.loadBackupSettings(); this.loadAllBooks(); @@ -205,8 +209,7 @@ struct SettingsPage { .showToast({ message: '备份目标已更新,下次同步生效' }); } - private async saveShowSystem(value: boolean): Promise { - if (this.context === undefined) { + private async saveShowSystem(value: boolean): Promise { if (this.context === undefined) { return; } await AppSettings.setShowSystemCalendar(this.context, value); @@ -244,6 +247,22 @@ struct SettingsPage { } } + private async saveDefaultView(view: string): Promise { + if (this.context === undefined) { + return; + } + this.defaultView = view; + await AppSettings.setDefaultView(this.context, view); + const label: string = view === 'month' ? '月视图' : (view === 'week' ? '周视图' : '列表视图'); + this.getUIContext().getPromptAction() + .showToast({ message: `默认视图已设为「${label}」,下次打开 App 生效` }); + } + + private defaultViewLabel(): string { + return this.defaultView === 'month' ? '月视图' + : (this.defaultView === 'week' ? '周视图' : '列表视图'); + } + private intervalLabel(minutes: number): string { return minutes >= 60 ? `${minutes / 60} 小时` : `${minutes} 分钟`; } @@ -390,6 +409,43 @@ struct SettingsPage { .backgroundColor($r('app.color.card_bg')) .border({ width: 1, color: $r('app.color.shadow_color') }) + // 默认视图:打开 App 后默认展示的视图(月/周/列表) + Column({ space: 8 }) { + Row({ space: 10 }) { + Column({ space: 2 }) { + Text('默认视图') + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_primary')) + Text('打开 App 后默认展示的视图(月 / 周 / 列表)') + .fontSize(12) + .fontColor($r('app.color.text_secondary')) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + Select([{ value: '月视图' }, { value: '周视图' }, { value: '列表视图' }] as SelectOption[]) + .selected(this.defaultView === 'month' ? 0 : (this.defaultView === 'week' ? 1 : 2)) + .value(this.defaultViewLabel()) + .fontColor($r('app.color.text_primary')) + .font({ size: 14 }) + .optionFont({ size: 14 }) + .selectedOptionFont({ size: 14 }) + .onSelect((index: number) => { + const v: string = index === 0 ? 'month' : (index === 1 ? 'week' : 'agenda'); + if (v !== this.defaultView) { + this.saveDefaultView(v); + } + }) + } + .width('100%') + } + .alignItems(HorizontalAlign.Start) + .width('100%') + .padding(14) + .borderRadius(12) + .backgroundColor($r('app.color.card_bg')) + .border({ width: 1, color: $r('app.color.shadow_color') }) + // 数据修复:手动触发一次性全量重拉(重建提醒等本地残缺字段) Column({ space: 8 }) { Row({ space: 10 }) { diff --git a/entry/src/main/ets/pages/widget/Widget2x2.ets b/entry/src/main/ets/pages/widget/Widget2x2.ets index 626cd02..2d6985c 100644 --- a/entry/src/main/ets/pages/widget/Widget2x2.ets +++ b/entry/src/main/ets/pages/widget/Widget2x2.ets @@ -11,7 +11,6 @@ struct Widget2x2Card { @LocalStorageProp('dateMd') dateMd: string = ''; @LocalStorageProp('weekday') weekday: string = ''; @LocalStorageProp('todayCount') todayCount: number = 0; - @LocalStorageProp('ongoingCount') ongoingCount: number = 0; // 此刻正在进行的日程条数 /** 右上角添加按钮:拉起 App 直接进入新建日程页(阻止冒泡,避免同时打开 App 首页) */ @Builder @@ -80,16 +79,9 @@ struct Widget2x2Card { .fontColor('#007DFF') Column({ space: 2 }) { Row({ space: 4 }) { - if (this.ongoingCount > 0) { - Column() - .width(8) - .height(8) - .borderRadius(4) - .backgroundColor('#FF3B30') - } - Text(this.ongoingCount > 0 ? `进行中 ${this.ongoingCount}` : '今日日程') + Text('今日日程') .fontSize(12) - .fontColor(this.ongoingCount > 0 ? '#FF3B30' : '#1A1A1A') + .fontColor('#1A1A1A') } Text(this.todayCount === 0 ? '点击添加' : '点击查看') .fontSize(10) diff --git a/entry/src/main/ets/pages/widget/Widget4x2.ets b/entry/src/main/ets/pages/widget/Widget4x2.ets index 8f2773b..d2b8a57 100644 --- a/entry/src/main/ets/pages/widget/Widget4x2.ets +++ b/entry/src/main/ets/pages/widget/Widget4x2.ets @@ -1,6 +1,7 @@ // entry/src/main/ets/pages/widget/Widget4x2.ets // 2x4 服务卡片:2x2 的横向扩展。左侧 = 日期/星期/农历 + 今日日程条数(与 2x2 一致); -// 右侧 = 当前正在进行(或下一个)的 1~3 条日程,不做滚动;整卡点击进入 App,右上角 + 直接新建日程 +// 右侧 = **当前时间之后最近的两条**日程(不含全天/跨天,不显示"进行中"等字样,不显示日历本名); +// 整卡点击进入 App,右上角 + 直接新建日程 let storage2x4 = new LocalStorage(); /** 与 eventsJson 同结构的卡片单条日程(本卡片只用到标题/时间/日历色/进行中标记) */ @@ -21,14 +22,13 @@ struct Widget4x2Card { @LocalStorageProp('weekday') weekday: string = ''; @LocalStorageProp('lunarText') lunarText: string = ''; @LocalStorageProp('todayCount') todayCount: number = 0; - @LocalStorageProp('ongoingCount') ongoingCount: number = 0; @LocalStorageProp('ongoingJson') ongoingJson: string = '[]'; - /** 右侧最多渲染 3 条(数据侧已裁剪,这里再兜底截断) */ + /** 右侧最多渲染 2 条(数据侧已裁剪,这里再兜底截断) */ private parseOngoing(): CardItem2x4[] { try { const all: CardItem2x4[] = JSON.parse(this.ongoingJson) as CardItem2x4[]; - return all.slice(0, 3); + return all.slice(0, 2); } catch (err) { return []; } @@ -66,20 +66,21 @@ struct Widget4x2Card { }); } - /** 右侧"正在进行 / 下一个"单条:左色竖条 + 时间列 + 标题 + 日历本名;进行中用红色高亮 */ + /** 右侧单条:左色竖条 + 起止时间 + 标题。 + * 不显示"进行中 / 即将开始"字样,也不显示日历本名 */ @Builder ongoingRow(item: CardItem2x4) { - Row({ space: 7 }) { + Row({ space: 8 }) { Column() .width(3) - .height(34) + .height(40) .borderRadius(2) - .backgroundColor(item.isNow ? '#FF3B30' : item.color) + .backgroundColor(item.color) Column({ space: 2 }) { Text(item.time) - .fontSize(9) + .fontSize(10) .fontWeight(FontWeight.Medium) - .fontColor(item.isNow ? '#FF3B30' : '#333333') + .fontColor('#333333') .maxLines(1) if (item.endTime !== '' && item.endTime !== '全天') { Text(item.endTime) @@ -88,50 +89,20 @@ struct Widget4x2Card { .maxLines(1) } } - .width(34) - .alignItems(HorizontalAlign.Start) - Column({ space: 2 }) { - Text(item.title) - .fontSize(12) - .fontWeight(item.isNow ? FontWeight.Medium : FontWeight.Normal) - .fontColor(item.isNow ? '#FF3B30' : '#1A1A1A') - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .width('100%') - Row({ space: 4 }) { - if (item.isNow) { - Text('● 进行中') - .fontSize(8) - .fontColor('#FF3B30') - .padding({ left: 4, right: 4, top: 0, bottom: 0 }) - .borderRadius(4) - .backgroundColor('#FFECEA') - } else { - Text('即将开始') - .fontSize(8) - .fontColor('#8A8A8A') - .padding({ left: 4, right: 4, top: 0, bottom: 0 }) - .borderRadius(4) - .backgroundColor('#F0F1F3') - } - if (item.calName !== '') { - Text(item.calName) - .fontSize(8) - .fontColor(item.color) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .constraintSize({ maxWidth: '55%' }) - } - } - } - .layoutWeight(1) + .width(36) .alignItems(HorizontalAlign.Start) + Text(item.title) + .fontSize(13) + .fontColor('#1A1A1A') + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) } .alignItems(VerticalAlign.Center) .width('100%') - .padding({ left: 7, right: 7, top: 4, bottom: 4 }) + .padding({ left: 8, right: 8, top: 5, bottom: 5 }) .borderRadius(8) - .backgroundColor(item.isNow ? '#FFF5F4' : '#F5F7FA') + .backgroundColor('#F5F7FA') } /** 右侧空态:今日无日程 / 日程已全部结束 */ @@ -187,16 +158,9 @@ struct Widget4x2Card { .fontColor('#007DFF') Column({ space: 2 }) { Row({ space: 4 }) { - if (this.ongoingCount > 0) { - Column() - .width(7) - .height(7) - .borderRadius(4) - .backgroundColor('#FF3B30') - } - Text(this.ongoingCount > 0 ? `进行中 ${this.ongoingCount}` : '今日日程') + Text('今日日程') .fontSize(11) - .fontColor(this.ongoingCount > 0 ? '#FF3B30' : '#1A1A1A') + .fontColor('#1A1A1A') .maxLines(1) } Text(this.todayCount === 0 ? '点击添加' : '点击查看') @@ -220,12 +184,12 @@ struct Widget4x2Card { .strokeWidth(0.5) .color('#E5E5E5') - // ===== 右半:正在进行 / 下一个(不滚动,最多 3 条)===== + // ===== 右半:当前时间之后最近的两条日程(不滚动)===== Column() { if (this.parseOngoing().length === 0) { this.emptyHint() } else { - Column({ space: 5 }) { + Column({ space: 6 }) { ForEach(this.parseOngoing(), (item: CardItem2x4, idx: number) => { this.ongoingRow(item) }, (item: CardItem2x4, idx: number) => `${idx}_${item.title}_${item.time}`) diff --git a/entry/src/main/ets/pages/widget/Widget4x4.ets b/entry/src/main/ets/pages/widget/Widget4x4.ets index 6128f03..3797458 100644 --- a/entry/src/main/ets/pages/widget/Widget4x4.ets +++ b/entry/src/main/ets/pages/widget/Widget4x4.ets @@ -1,39 +1,369 @@ // entry/src/main/ets/pages/widget/Widget4x4.ets -// 4x4 服务卡片:日期 + 农历 + 从今天开始的日程(时间轴样式,按天分组,可滑动) +// 4x4 服务卡片:今日时间轴 —— 与 6x4 完全同一套逻辑,只是尺寸更小: +// · 用 List 包一个"很高的 ListItem"(整日时间轴按真实 vp 高度撑开)→ 卡片内可以上下滑动 +// (官方卡片能力清单里 List / ListItem 支持,Scroll 与 Scroller 不支持) +// · 视窗截断:只渲染"最早日程 ~ 最晚日程(+ 当前时刻)"这一段,避免上下大片空白 +// · 三层堆叠:网格层 / 日程层 / 红线层,色块永不被切割 +// · 贪心分列:列数 = 最大同时重叠数 +// · 全天日程同样用色块展示 +// ListItem 内必须用**确定 vp 高度**(不能再用百分比,否则撑不开 → 无法滚动)。 let storage4x4 = new LocalStorage(); -class CardItem4x4 { +/** 时间轴色块(与 common/TimelineUtil.TimelineBlock 结构一致,卡片侧只渲染需要的字段) */ +class TBlock { + eventKey: string = ''; title: string = ''; - time: string = ''; - endTime: string = ''; - date: string = ''; - showDate: boolean = false; - calName: string = ''; + timeText: string = ''; color: string = '#007DFF'; - // 红线:startMs/endMs 实际起止;showNowLine 上方画红线;isNow 正在进行;nowLineBelow 画在底部 - startMs: number = 0; - endMs: number = 0; - showNowLine: boolean = false; + topRatio: number = 0; + heightRatio: number = 0; + leftRatio: number = 0; + widthRatio: number = 1; + groupIndex: number = 0; isNow: boolean = false; - nowLineBelow: boolean = false; } +/** 全天日程(eventKey 由 CardDataService 写入,用于 ForEach 唯一标识) */ +class TAllDay { + eventKey: string = ''; + title: string = ''; + color: string = '#007DFF'; + isAllDay: boolean = true; +} + +/** 冲突组:组内**贪心分列**,lanes[i] = 第 i 列(同列互不重叠) */ +class TGroup4 { + startRatio: number = 0; + endRatio: number = 0; + laneCount: number = 1; + blocks: TBlock[] = []; + lanes: TBlock[][] = []; +} + +/** 日程层纵向序列中的一行:空隙 / 冲突组(线都在其它两层) */ +class T4Row { + kind: number = 0; // 0=空隙 1=冲突组 + from: number = 0; + to: number = 0; + group: TGroup4 = new TGroup4(); + key: string = ''; +} + +/** 视窗范围(小时) */ +class W4Range { + s: number = 0; + e: number = 0; +} + +/** 每 1 小时的高度(vp)。整日 24h × 36 = 864vp,远超卡片高度 → 可以滑动 */ +const W4_HOUR: number = 36; +/** 全天日程最多显示条数(超出折叠为"还有 N 个") */ +const W4_ALLDAY_MAX: number = 2; +/** 左侧刻度占宽 */ +const W4_GUTTER: number = 26; +/** 全天色块单条高度 / 间距 */ +const W4_ALLDAY_H: number = 18; + @Entry(storage4x4) @Component struct Widget4x4Card { - @LocalStorageProp('eventsJson') eventsJson: string = '[]'; @LocalStorageProp('dateText') dateText: string = ''; + @LocalStorageProp('dateMd') dateMd: string = ''; @LocalStorageProp('lunarText') lunarText: string = ''; + @LocalStorageProp('timelineJson') timelineJson: string = '[]'; + @LocalStorageProp('allDayJson') allDayJson: string = '[]'; + @LocalStorageProp('nowRatio') nowRatio: number = 0; + @LocalStorageProp('nowLabel') nowLabel: string = ''; + @LocalStorageProp('todayCount') todayCount: number = 0; + @LocalStorageProp('isToday') isToday: boolean = true; + @LocalStorageProp('dayCount') dayCount: number = 0; - private parseItems(): CardItem4x4[] { + private parseBlocks(): TBlock[] { try { - return JSON.parse(this.eventsJson) as CardItem4x4[]; + return JSON.parse(this.timelineJson) as TBlock[]; } catch (err) { return []; } } - /** 右上角添加按钮:拉起 App 直接进入新建日程页(阻止冒泡,避免同时打开 App 首页) */ + /** 当前时间比例(无效返回 -1) */ + private w4NowR(): number { + return (this.nowRatio > 0.001 && this.nowRatio < 0.999) ? this.nowRatio : -1; + } + private w4IsNowHour(h: number): boolean { + const r: number = this.w4NowR(); + return r >= 0 && Math.floor(r * 24) === h; + } + + /** 视窗范围:最早日程 ~ 最晚日程结束(今天再并入当前时刻)。 + * 不做"最大跨度截断" —— 现在可以滑动了,没必要砍掉日程。 */ + private w4Range(): W4Range { + const rg = new W4Range(); + const bs: TBlock[] = this.parseBlocks(); + let s: number = -1; + let e: number = -1; + for (const b of bs) { + const bh: number = Math.floor(b.topRatio * 24); + const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001); + if (s < 0 || bh < s) { + s = bh; + } + if (e < 0 || eh > e) { + e = eh; + } + } + const r: number = this.w4NowR(); + // 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示); + // 定时日程已全部结束 → 不并入 → 时间刻度直接截断到最晚日程 + const nh: number = (r >= 0 && !this.w4AllTimedEnded()) ? Math.floor(r * 24) : -1; + if (s < 0) { + s = nh >= 0 ? nh : 8; + } + if (e < 0) { + e = nh >= 0 ? nh + 1 : 20; + } + if (nh >= 0) { + if (nh < s) { + s = nh; + } + if (nh + 1 > e) { + e = nh + 1; + } + } + if (s < 0) { + s = 0; + } + if (e > 24) { + e = 24; + } + if (e <= s) { + e = s + 1 > 24 ? 24 : s + 1; + } + rg.s = s; + rg.e = e; + return rg; + } + private w4StartHour(): number { + return this.w4Range().s; + } + private w4EndHour(): number { + return this.w4Range().e; + } + /** 视窗跨度(小时) */ + private w4Span(): number { + return this.w4EndHour() - this.w4StartHour(); + } + /** 时间轴内容总高(vp)= 跨度 × 每小时高度 */ + private w4ContentH(): number { + return this.w4Span() * W4_HOUR; + } + /** 一段(起止为当日比例)换算成 vp 高度 */ + private w4SpanVp(from: number, to: number): number { + const v: number = (to - from) * 24 * W4_HOUR; + return v > 0 ? v : 0; + } + + /** 全天区高度(vp),没有全天日程时为 0 */ + private w4AllDayH(): number { + const n: number = this.w4AllDay().length; + if (n === 0) { + return 0; + } + let h: number = n * W4_ALLDAY_H; + if (this.w4AllDayRest() > 0) { + h += 13; + } + return h + 4; + } + /** ListItem 总高(vp)= 全天区 + 时间轴 */ + private w4ItemH(): number { + return this.w4AllDayH() + this.w4ContentH(); + } + + /** 由色块重建冲突分组:组内按开始时间升序做**贪心分列**(列数 = 最大同时重叠数) */ + private w4Groups(): TGroup4[] { + const map: Map = new Map(); + for (const b of this.parseBlocks()) { + const arr: TBlock[] | undefined = map.get(b.groupIndex); + if (arr === undefined) { + map.set(b.groupIndex, [b]); + } else { + arr.push(b); + } + } + const idxs: number[] = Array.from(map.keys()).sort((a: number, b: number): number => a - b); + const out: TGroup4[] = []; + for (const gi of idxs) { + const bs: TBlock[] = map.get(gi) ?? []; + bs.sort((a: TBlock, b: TBlock): number => { + if (a.topRatio !== b.topRatio) { + return a.topRatio - b.topRatio; + } + return b.heightRatio - a.heightRatio; + }); + const lanes: TBlock[][] = []; + const laneEnd: number[] = []; + for (const b of bs) { + let li: number = -1; + for (let i = 0; i < laneEnd.length; i++) { + if (laneEnd[i] <= b.topRatio + 0.0000001) { + li = i; + break; + } + } + const be: number = b.topRatio + b.heightRatio; + if (li < 0) { + li = lanes.length; + lanes.push([b]); + laneEnd.push(be); + } else { + lanes[li].push(b); + laneEnd[li] = be; + } + } + const g = new TGroup4(); + g.blocks = bs; + g.lanes = lanes; + g.laneCount = lanes.length > 0 ? lanes.length : 1; + let s: number = 1; + let e: number = 0; + for (const b of bs) { + if (b.topRatio < s) { + s = b.topRatio; + } + const be: number = b.topRatio + b.heightRatio; + if (be > e) { + e = be; + } + } + g.startRatio = s; + g.endRatio = e; + out.push(g); + } + return out; + } + + /** 日程层纵向序列:空隙 / 冲突组(不含任何"线") */ + private w4Rows(): T4Row[] { + const rows: T4Row[] = []; + const gs: TGroup4[] = this.w4Groups(); + const top: number = this.w4StartHour() / 24; + const bot: number = this.w4EndHour() / 24; + let cursor: number = top; + for (const g of gs) { + const gsx: number = g.startRatio > top ? g.startRatio : top; + const gex: number = g.endRatio < bot ? g.endRatio : bot; + if (gex - gsx <= 0.0002) { + continue; + } + this.w4Push(rows, cursor, gsx, null); + this.w4Push(rows, gsx, gex, g); + cursor = gex; + } + this.w4Push(rows, cursor, bot, null); + return rows; + } + private w4Push(rows: T4Row[], from: number, to: number, g: TGroup4 | null): void { + if (to - from <= 0.0002) { + return; + } + const r = new T4Row(); + r.from = from; + r.to = to; + if (g === null) { + r.kind = 0; + r.key = `g_${rows.length}_${Math.round((to - from) * 100000)}`; + } else { + r.kind = 1; + r.group = g; + // key 必须绑定"内容":翻页后行序号不变、只有内容变了; + // 若只用序号,ArkUI 会认为是同一批元素而不重绘 → 残留上一天的数据 + r.key = `b_${rows.length}_${this.w4GroupKey(g, from, to)}`; + } + rows.push(r); + } + + /** 冲突组的内容签名(时间窗 + 各块 eventKey),供 ForEach key 使用 */ + private w4GroupKey(g: TGroup4, from: number, to: number): string { + let sig: string = `${Math.round(from * 100000)}_${Math.round(to * 100000)}`; + for (const b of g.blocks) { + sig = `${sig}_${b.eventKey}`; + } + return sig; + } + + /** 列内第 index 个色块之前的空隙(vp)= 本块顶 − 上一块底 */ + private w4LanePadVp(g: TGroup4, lane: TBlock[], index: number): number { + let prevEnd: number = g.startRatio; + if (index > 0) { + const p: TBlock = lane[index - 1]; + const pe: number = p.topRatio + p.heightRatio; + prevEnd = pe > g.startRatio ? pe : g.startRatio; + } + return this.w4SpanVp(prevEnd, lane[index].topRatio); + } + /** 色块高度(vp) */ + private w4BlockVp(b: TBlock): number { + return b.heightRatio * 24 * W4_HOUR; + } + /** 今天定时日程是否已全部结束(最晚结束比例 <= 当前时刻比例);只剩全天 / 无定时日程也算已结束。 + * 非今天(r<0)返回 false,交由原条件判断(非今天本来就不显示红线)。 */ + private w4AllTimedEnded(): boolean { + const r: number = this.w4NowR(); + if (r < 0) { + return false; + } + let maxEnd: number = 0; + for (const b of this.parseBlocks()) { + const e: number = b.topRatio + b.heightRatio; + if (e > maxEnd) { + maxEnd = e; + } + } + return r >= maxEnd - 0.0001; + } + /** 红线是否显示(当前时刻落在视窗内 + 今天还有未结束的定时日程) */ + private w4NowVisible(): boolean { + const r: number = this.w4NowR(); + return r >= 0 && !this.w4AllTimedEnded() + && r >= this.w4StartHour() / 24 - 0.0001 && r <= this.w4EndHour() / 24 + 0.0001; + } + /** 红线距内容顶部的偏移(vp) */ + private w4NowPadVp(): number { + const v: number = (this.w4NowR() - this.w4StartHour() / 24) * 24 * W4_HOUR; + return v > 0 ? v : 0; + } + + private parseAllDay(): TAllDay[] { + try { + return JSON.parse(this.allDayJson) as TAllDay[]; + } catch (err) { + return []; + } + } + /** 卡片空间有限:全天最多显示 W4_ALLDAY_MAX 条 */ + private w4AllDay(): TAllDay[] { + return this.parseAllDay().slice(0, W4_ALLDAY_MAX); + } + private w4AllDayRest(): number { + const n: number = this.parseAllDay().length - W4_ALLDAY_MAX; + return n > 0 ? n : 0; + } + + /** 只渲染视窗内的小时 */ + private hourLabels(): number[] { + const arr: number[] = []; + for (let h = this.w4StartHour(); h < this.w4EndHour(); h++) { + arr.push(h); + } + return arr; + } + + private hourText(h: number): string { + return h < 10 ? `0${h}` : `${h}`; + } + + /** 右上角添加按钮:拉起 App 直接进入新建日程页(兄弟节点布局,不冒泡到其它点击区) */ @Builder addButton() { Button() { @@ -56,120 +386,6 @@ struct Widget4x4Card { }) } - /** 全天事件行:圆角矩形 + 左侧日历色竖条 + 标题 + “全天”标记(无时间轴) */ - @Builder - buildAllDayRow(item: CardItem4x4) { - Row({ space: 8 }) { - Column() - .width(3) - .height(16) - .borderRadius(2) - .backgroundColor(item.color) - Text(item.title) - .fontSize(12) - .fontColor('#1A1A1A') - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) - Text('全天') - .fontSize(9) - .fontColor('#FFFFFF') - .backgroundColor(item.color) - .borderRadius(6) - .padding({ left: 5, right: 5, top: 1, bottom: 1 }) - if (item.calName !== '') { - Text(item.calName) - .fontSize(9) - .fontColor(item.color) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .constraintSize({ maxWidth: '25%' }) - } - } - .alignItems(VerticalAlign.Center) - .width('100%') - .padding({ left: 8, right: 8, top: 4, bottom: 4 }) - .borderRadius(8) - .backgroundColor('#F5F7FA') - } - - /** 当前时间红线标记:左侧红点 + 贯穿整行的红色细线 */ - @Builder - nowLine() { - Row() { - Column() - .width(6) - .height(6) - .borderRadius(3) - .backgroundColor('#FF3B30') - Column() - .height(2) - .layoutWeight(1) - .backgroundColor('#FF3B30') - .borderRadius(1) - } - .width('100%') - .padding({ top: 3, bottom: 3 }) - } - - /** 有时间事件行(时间轴):圆角矩形 + 左色竖条 + 开始时间(上)-竖线-结束时间(下) + 标题 */ - @Builder - buildTimedRow(item: CardItem4x4) { - Row({ space: 8 }) { - Column() - .width(3) - .height(38) - .borderRadius(2) - .backgroundColor(item.isNow ? '#FF3B30' : item.color) - // 时间列:开始时间在上、结束时间在下、中间竖线连接 - Column({ space: 2 }) { - Text(item.time) - .fontSize(10) - .fontWeight(FontWeight.Medium) - .fontColor(item.isNow ? '#FF3B30' : '#333333') - Column() - .width(1.5) - .layoutWeight(1) - .backgroundColor('#D8D8D8') - .borderRadius(1) - Text(item.endTime) - .fontSize(10) - .fontColor('#999999') - } - .width(38) - .alignItems(HorizontalAlign.Center) - .height(38) - Text(item.title) - .fontSize(12) - .fontColor(item.isNow ? '#FF3B30' : '#1A1A1A') - .maxLines(2) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) - if (item.isNow) { - Text('● 进行中') - .fontSize(9) - .fontColor('#FF3B30') - .padding({ left: 4, right: 4, top: 1, bottom: 1 }) - .borderRadius(4) - .backgroundColor('#FFECEA') - } - if (item.calName !== '') { - Text(item.calName) - .fontSize(9) - .fontColor(item.color) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .constraintSize({ maxWidth: '25%' }) - } - } - .alignItems(VerticalAlign.Center) - .width('100%') - .padding({ left: 8, right: 8, top: 5, bottom: 5 }) - .borderRadius(8) - .backgroundColor(item.isNow ? '#FFF1F0' : '#F5F7FA') - } - - /** 卡片"打开 App":点击日期区或日程列表触发;添加按钮是 header 行的兄弟节点,其点击不会冒泡到这里 */ private openApp(): void { postCardAction(this, { action: 'router', @@ -178,80 +394,266 @@ struct Widget4x4Card { }); } + /** 翻页:交给 FormExtensionAbility.onFormEvent 重新取数并推送(跨天查看) */ + private page(act: string): void { + postCardAction(this, { + action: 'message', + params: { pageAction: act } + }); + } + + /** 上一天 / 下一天 圆按钮 */ + @Builder + private pageBtn(label: string, act: string) { + Button() { + Text(label) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor('#333333') + } + .width(22) + .height(22) + .borderRadius(11) + .padding(0) + .backgroundColor('#F2F3F5') + .onClick(() => this.page(act)) + } + + /** 今天时是"+"(新建日程);非今天时是"今"(回到今天) */ + @Builder + private sideBtn() { + if (this.isToday) { + this.addButton() + } else { + Button() { + Text('今') + .fontSize(12) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') + } + .width(22) + .height(22) + .borderRadius(11) + .padding(0) + .backgroundColor('#007DFF') + .onClick(() => this.page('today')) + } + } + build() { Column({ space: 6 }) { - Row({ space: 6 }) { - Row({ space: 6 }) { - Text(this.dateText) - .fontSize(16) - .fontWeight(FontWeight.Bold) - .fontColor('#1A1A1A') - Text(this.lunarText) + Row({ space: 4 }) { + this.pageBtn('‹', 'prev') + Column() { + Text(this.dateMd) .fontSize(12) + .fontWeight(FontWeight.Bold) + .fontColor(this.isToday ? '#007DFF' : '#1A1A1A') + .maxLines(1) + Text(this.lunarText) + .fontSize(9) .fontColor('#8A8A8A') + .maxLines(1) } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) .onClick(() => this.openApp()) - Blank() - Text('同步日历') - .fontSize(10) - .fontColor('#B0B0B0') - this.addButton() + this.pageBtn('›', 'next') + this.sideBtn() } .width('100%') Divider().strokeWidth(0.5).color('#E5E5E5') - if (this.parseItems().length === 0) { - Column({ space: 6 }) { - Text('📅') - .fontSize(24) - Text('暂无日程') - .fontSize(13) - .fontColor('#8A8A8A') - } - .width('100%') - .layoutWeight(1) - .justifyContent(FlexAlign.Center) - .onClick(() => this.openApp()) - } else { - List({ space: 4 }) { - ForEach(this.parseItems(), (item: CardItem4x4, idx: number) => { - ListItem() { - Column({ space: 3 }) { - if (item.showDate) { - Text(item.date) - .fontSize(10) - .fontWeight(FontWeight.Bold) - .fontColor('#666666') + // 卡片不支持 Scroll,但支持 List / ListItem: + // 把"整日时间轴"作为**一个很高的 ListItem**,超出卡片窗口的部分靠上下滑动查看 + List() { + ListItem() { + Column() { + // 全天 / 跨天:同样用**色块**展示(与列表视图一致),随内容一起滚动 + if (this.w4AllDay().length > 0) { + Column({ space: 2 }) { + ForEach(this.w4AllDay(), (a: TAllDay) => { + Row({ space: 4 }) { + Text(a.isAllDay ? '全天' : '跨天') + .fontSize(8) + .fontColor('#FFFFFF') + .backgroundColor('#26000000') + .borderRadius(5) + .padding({ left: 4, right: 4, top: 1, bottom: 1 }) + Text(a.title) + .fontSize(10) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + } + .alignItems(VerticalAlign.Center) + .width('100%') + .height(W4_ALLDAY_H - 2) + .padding({ left: 5, right: 5 }) + .borderRadius(5) + .backgroundColor(a.color) + .onClick(() => this.openApp()) + }, (a: TAllDay, idx: number) => `ad_${idx}_${a.eventKey}`) + if (this.w4AllDayRest() > 0) { + Text(`还有 ${this.w4AllDayRest()} 个全天日程`) + .fontSize(9) + .fontColor('#8A8A8A') .width('100%') - } - if (item.showNowLine && !item.nowLineBelow) { - this.nowLine() - } - if (item.time === '全天') { - this.buildAllDayRow(item) - } else { - this.buildTimedRow(item) - } - if (item.nowLineBelow) { - this.nowLine() + .padding({ left: 2 }) } } .width('100%') + .padding({ bottom: 2 }) } - }, (item: CardItem4x4, idx: number) => `${idx}_${item.title}_${item.time}`) + + // 时间轴主体:左侧小时刻度(放在 Stack 外,避免被色块盖住)+ 右侧三层堆叠 + Row() { + Column() { + ForEach(this.hourLabels(), (h: number) => { + Text(this.hourText(h)) + .fontSize(9) + .fontColor(this.w4IsNowHour(h) ? '#FF3B30' : '#9AA0A6') + .width(W4_GUTTER) + .height(W4_HOUR) + .textAlign(TextAlign.End) + .padding({ right: 3 }) + .border({ width: { bottom: 0.5 }, color: { bottom: '#14000000' } }) + }, (h: number) => `h${h}`) + } + .width(W4_GUTTER) + .height(this.w4ContentH()) + + Stack() { + this.w4GridLayer() // 第 1 层:整点网格线 + this.w4EventLayer() // 第 2 层:日程色块 + this.w4NowLayer() // 第 3 层:当前时间红线 + } + .layoutWeight(1) + .height(this.w4ContentH()) + .alignContent(Alignment.TopStart) + .border({ width: { left: 0.5 }, color: { left: '#14000000' } }) + } + .width('100%') + .height(this.w4ContentH()) + .alignItems(VerticalAlign.Top) + } + .width('100%') + .height(this.w4ItemH()) } .width('100%') - .layoutWeight(1) - .scrollBar(BarState.Auto) - .cachedCount(8) - .onClick(() => this.openApp()) + .height(this.w4ItemH()) + } + .layoutWeight(1) + .width('100%') + } + .width('100%') + .height('100%') + .padding(12) + .backgroundColor('#FFFFFF') + .borderRadius(16) + } + + /** 第 1 层:整点网格(每小时一格,格底一条灰线) */ + @Builder + private w4GridLayer() { + Column() { + ForEach(this.hourLabels(), (h: number) => { + Column() + .width('100%') + .height(W4_HOUR) + .border({ width: { bottom: 0.5 }, color: { bottom: '#14000000' } }) + .hitTestBehavior(HitTestMode.None) // 装饰层子节点同样不参与命中测试 + }, (h: number) => `gd_${h}`) + } + .width('100%') + .height('100%') + .hitTestBehavior(HitTestMode.None) // 装饰层:不参与命中测试,触碰事件穿透到下层色块 + } + + /** 第 2 层:日程色块(冲突组分行 + 组内 lane 分列) */ + @Builder + private w4EventLayer() { + Column() { + ForEach(this.w4Rows(), (r: T4Row) => { + if (r.kind === 1) { + Row() { + ForEach(r.group.lanes, (lane: TBlock[], li: number) => { + Column() { + ForEach(lane, (b: TBlock, index: number) => { + Blank().height(this.w4LanePadVp(r.group, lane, index)) + Column({ space: 1 }) { + Text(b.title) + .fontSize(10) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + if (b.heightRatio * 86400000 >= 60 * 60000) { + Text(b.timeText) + .fontSize(8) + .fontColor('#E6FFFFFF') + .maxLines(1) + .width('100%') + } + } + .alignItems(HorizontalAlign.Start) + .padding({ left: 4, right: 2, top: 1, bottom: 1 }) + .borderRadius(4) + .backgroundColor(b.color) + .opacity(b.isNow ? 1 : 0.92) + .clip(true) + .width('100%') + .height(this.w4BlockVp(b)) + .constraintSize({ minHeight: 14 }) + .onClick(() => this.openApp()) + }, (b: TBlock) => `b_${r.key}_${b.eventKey}`) + } + .layoutWeight(1) + .height('100%') + .padding({ right: 2 }) + .clip(true) + }, (lane: TBlock[], li: number) => `ln_${r.key}_${li}`) + } + .width('100%') + .height(this.w4SpanVp(r.from, r.to)) + .alignItems(VerticalAlign.Top) + } else { + Blank().height(this.w4SpanVp(r.from, r.to)) + } + }, (r: T4Row) => r.key) + } + .width('100%') + .height('100%') + } + + /** 第 3 层:当前时间红线(浮在最上层) */ + @Builder + private w4NowLayer() { + Column() { + if (this.w4NowVisible()) { + Blank().height(this.w4NowPadVp()).hitTestBehavior(HitTestMode.None) // 占位块:不拦截点击 + Row() { + Column() + .width(5) + .height(5) + .borderRadius(3) + .backgroundColor('#FF3B30') + Column() + .height(2) + .layoutWeight(1) + .backgroundColor('#FF3B30') + .borderRadius(1) + } + .width('100%') + .hitTestBehavior(HitTestMode.None) // 红线本身也不拦截点击,穿透到色块层 } } .width('100%') .height('100%') - .padding(14) - .backgroundColor('#FFFFFF') - .borderRadius(16) + .hitTestBehavior(HitTestMode.None) // 装饰层:整层不参与命中测试,触碰事件穿透到下层色块 } } diff --git a/entry/src/main/ets/pages/widget/Widget6x4.ets b/entry/src/main/ets/pages/widget/Widget6x4.ets index 3067dc4..b7148b4 100644 --- a/entry/src/main/ets/pages/widget/Widget6x4.ets +++ b/entry/src/main/ets/pages/widget/Widget6x4.ets @@ -1,39 +1,366 @@ // entry/src/main/ets/pages/widget/Widget6x4.ets -// 6x4 服务卡片:日期 + 农历 + 从今天开始的日程(时间轴样式,比 4x4 显示更多) +// 6x4 服务卡片:今日时间轴 —— 与"列表视图"完全同一套逻辑,并且**可以上下滑动**: +// · 用 List 包一个"很高的 ListItem"(整日时间轴按真实 vp 高度撑开) +// —— 官方卡片能力清单里 List / ListItem 是支持的,Scroll 不支持,所以只能走 List。 +// · 视窗截断:只渲染"最早日程 ~ 最晚日程(+ 当前时刻)"这一段,避免上下大片空白 +// · 三层堆叠:网格层 / 日程层 / 红线层,色块永不被切割 +// · 贪心分列:列数 = 最大同时重叠数 +// · 全天日程同样用色块展示 +// ListItem 内必须用**确定 vp 高度**(不能再用百分比,否则撑不开 → 无法滚动)。 let storage6x4 = new LocalStorage(); -class CardItem6x4 { +/** 时间轴色块(与 common/TimelineUtil.TimelineBlock 结构一致) */ +class TBlock6 { + eventKey: string = ''; title: string = ''; - time: string = ''; - endTime: string = ''; - date: string = ''; - showDate: boolean = false; - calName: string = ''; + timeText: string = ''; color: string = '#007DFF'; - // 红线:startMs/endMs 实际起止;showNowLine 上方画红线;isNow 正在进行;nowLineBelow 画在底部 - startMs: number = 0; - endMs: number = 0; - showNowLine: boolean = false; + topRatio: number = 0; + heightRatio: number = 0; + leftRatio: number = 0; + widthRatio: number = 1; + groupIndex: number = 0; isNow: boolean = false; - nowLineBelow: boolean = false; } +/** 全天日程(eventKey 由 CardDataService 写入,用于 ForEach 唯一标识) */ +class TAllDay6 { + eventKey: string = ''; + title: string = ''; + color: string = '#007DFF'; + isAllDay: boolean = true; +} + +/** 冲突组:组内**贪心分列**,lanes[i] = 第 i 列(同列互不重叠) */ +class TGroup6 { + startRatio: number = 0; + endRatio: number = 0; + laneCount: number = 1; + blocks: TBlock6[] = []; + lanes: TBlock6[][] = []; +} + +/** 日程层纵向序列中的一行:空隙 / 冲突组(线都在其它两层) */ +class T6Row { + kind: number = 0; // 0=空隙 1=冲突组 + from: number = 0; + to: number = 0; + group: TGroup6 = new TGroup6(); + key: string = ''; +} + +/** 视窗范围(小时) */ +class W6Range { + s: number = 0; + e: number = 0; +} + +/** 每 1 小时的高度(vp)。整日 24h × 40 = 960vp,远超卡片高度 → 可以滑动 */ +const W6_HOUR: number = 40; +/** 全天日程最多显示条数(超出折叠为"还有 N 个") */ +const W6_ALLDAY_MAX: number = 2; +/** 左侧刻度占宽 */ +const W6_GUTTER: number = 36; +/** 全天色块单条高度 / 间距 */ +const W6_ALLDAY_H: number = 20; + @Entry(storage6x4) @Component struct Widget6x4Card { - @LocalStorageProp('eventsJson') eventsJson: string = '[]'; @LocalStorageProp('dateText') dateText: string = ''; @LocalStorageProp('lunarText') lunarText: string = ''; + @LocalStorageProp('timelineJson') timelineJson: string = '[]'; + @LocalStorageProp('allDayJson') allDayJson: string = '[]'; + @LocalStorageProp('nowRatio') nowRatio: number = 0; + @LocalStorageProp('nowLabel') nowLabel: string = ''; + @LocalStorageProp('todayCount') todayCount: number = 0; + @LocalStorageProp('isToday') isToday: boolean = true; + @LocalStorageProp('dayCount') dayCount: number = 0; - private parseItems(): CardItem6x4[] { + private parseBlocks(): TBlock6[] { try { - return JSON.parse(this.eventsJson) as CardItem6x4[]; + return JSON.parse(this.timelineJson) as TBlock6[]; } catch (err) { return []; } } - /** 右上角添加按钮:拉起 App 直接进入新建日程页(阻止冒泡,避免同时打开 App 首页) */ + /** 当前时间比例(无效返回 -1) */ + private w6NowR(): number { + return (this.nowRatio > 0.001 && this.nowRatio < 0.999) ? this.nowRatio : -1; + } + private w6IsNowHour(h: number): boolean { + const r: number = this.w6NowR(); + return r >= 0 && Math.floor(r * 24) === h; + } + + /** 视窗范围:最早日程 ~ 最晚日程结束(今天再并入当前时刻)。 + * 不做"最大跨度截断" —— 现在可以滑动了,没必要砍掉日程。 */ + private w6Range(): W6Range { + const rg = new W6Range(); + const bs: TBlock6[] = this.parseBlocks(); + let s: number = -1; + let e: number = -1; + for (const b of bs) { + const bh: number = Math.floor(b.topRatio * 24); + const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001); + if (s < 0 || bh < s) { + s = bh; + } + if (e < 0 || eh > e) { + e = eh; + } + } + const r: number = this.w6NowR(); + // 只有"今天 + 还有未结束的定时日程"时才把当前小时并入视窗(红线会显示); + // 定时日程已全部结束 → 不并入 → 时间刻度直接截断到最晚日程 + const nh: number = (r >= 0 && !this.w6AllTimedEnded()) ? Math.floor(r * 24) : -1; + if (s < 0) { + s = nh >= 0 ? nh : 8; + } + if (e < 0) { + e = nh >= 0 ? nh + 1 : 20; + } + if (nh >= 0) { + if (nh < s) { + s = nh; + } + if (nh + 1 > e) { + e = nh + 1; + } + } + if (s < 0) { + s = 0; + } + if (e > 24) { + e = 24; + } + if (e <= s) { + e = s + 1 > 24 ? 24 : s + 1; + } + rg.s = s; + rg.e = e; + return rg; + } + private w6StartHour(): number { + return this.w6Range().s; + } + private w6EndHour(): number { + return this.w6Range().e; + } + /** 视窗跨度(小时) */ + private w6Span(): number { + return this.w6EndHour() - this.w6StartHour(); + } + /** 时间轴内容总高(vp)= 跨度 × 每小时高度 */ + private w6ContentH(): number { + return this.w6Span() * W6_HOUR; + } + /** 一段(起止为当日比例)换算成 vp 高度 */ + private w6SpanVp(from: number, to: number): number { + const v: number = (to - from) * 24 * W6_HOUR; + return v > 0 ? v : 0; + } + + /** 全天区高度(vp),没有全天日程时为 0 */ + private w6AllDayH(): number { + const n: number = this.w6AllDay().length; + if (n === 0) { + return 0; + } + let h: number = n * W6_ALLDAY_H; + if (this.w6AllDayRest() > 0) { + h += 14; + } + return h + 4; + } + /** ListItem 总高(vp)= 全天区 + 时间轴 */ + private w6ItemH(): number { + return this.w6AllDayH() + this.w6ContentH(); + } + + /** 由色块重建冲突分组:组内按开始时间升序做**贪心分列**(列数 = 最大同时重叠数) */ + private w6Groups(): TGroup6[] { + const map: Map = new Map(); + for (const b of this.parseBlocks()) { + const arr: TBlock6[] | undefined = map.get(b.groupIndex); + if (arr === undefined) { + map.set(b.groupIndex, [b]); + } else { + arr.push(b); + } + } + const idxs: number[] = Array.from(map.keys()).sort((a: number, b: number): number => a - b); + const out: TGroup6[] = []; + for (const gi of idxs) { + const bs: TBlock6[] = map.get(gi) ?? []; + bs.sort((a: TBlock6, b: TBlock6): number => { + if (a.topRatio !== b.topRatio) { + return a.topRatio - b.topRatio; + } + return b.heightRatio - a.heightRatio; + }); + const lanes: TBlock6[][] = []; + const laneEnd: number[] = []; + for (const b of bs) { + let li: number = -1; + for (let i = 0; i < laneEnd.length; i++) { + if (laneEnd[i] <= b.topRatio + 0.0000001) { + li = i; + break; + } + } + const be: number = b.topRatio + b.heightRatio; + if (li < 0) { + li = lanes.length; + lanes.push([b]); + laneEnd.push(be); + } else { + lanes[li].push(b); + laneEnd[li] = be; + } + } + const g = new TGroup6(); + g.blocks = bs; + g.lanes = lanes; + g.laneCount = lanes.length > 0 ? lanes.length : 1; + let s: number = 1; + let e: number = 0; + for (const b of bs) { + if (b.topRatio < s) { + s = b.topRatio; + } + const be: number = b.topRatio + b.heightRatio; + if (be > e) { + e = be; + } + } + g.startRatio = s; + g.endRatio = e; + out.push(g); + } + return out; + } + + /** 日程层纵向序列:空隙 / 冲突组(不含任何"线") */ + private w6Rows(): T6Row[] { + const rows: T6Row[] = []; + const gs: TGroup6[] = this.w6Groups(); + const top: number = this.w6StartHour() / 24; + const bot: number = this.w6EndHour() / 24; + let cursor: number = top; + for (const g of gs) { + const gsx: number = g.startRatio > top ? g.startRatio : top; + const gex: number = g.endRatio < bot ? g.endRatio : bot; + if (gex - gsx <= 0.0002) { + continue; + } + this.w6Push(rows, cursor, gsx, null); + this.w6Push(rows, gsx, gex, g); + cursor = gex; + } + this.w6Push(rows, cursor, bot, null); + return rows; + } + private w6Push(rows: T6Row[], from: number, to: number, g: TGroup6 | null): void { + if (to - from <= 0.0002) { + return; + } + const r = new T6Row(); + r.from = from; + r.to = to; + if (g === null) { + r.kind = 0; + r.key = `g_${rows.length}_${Math.round((to - from) * 100000)}`; + } else { + r.kind = 1; + r.group = g; + // key 必须绑定"内容":翻页后行序号不变、只有内容变了; + // 若只用序号,ArkUI 会认为是同一批元素而不重绘 → 残留上一天的数据 + r.key = `b_${rows.length}_${this.w6GroupKey(g, from, to)}`; + } + rows.push(r); + } + + /** 冲突组的内容签名(时间窗 + 各块 eventKey),供 ForEach key 使用 */ + private w6GroupKey(g: TGroup6, from: number, to: number): string { + let sig: string = `${Math.round(from * 100000)}_${Math.round(to * 100000)}`; + for (const b of g.blocks) { + sig = `${sig}_${b.eventKey}`; + } + return sig; + } + + /** 列内第 index 个色块之前的空隙(vp)= 本块顶 − 上一块底 */ + private w6LanePadVp(g: TGroup6, lane: TBlock6[], index: number): number { + let prevEnd: number = g.startRatio; + if (index > 0) { + const p: TBlock6 = lane[index - 1]; + const pe: number = p.topRatio + p.heightRatio; + prevEnd = pe > g.startRatio ? pe : g.startRatio; + } + return this.w6SpanVp(prevEnd, lane[index].topRatio); + } + /** 色块高度(vp) */ + private w6BlockVp(b: TBlock6): number { + return b.heightRatio * 24 * W6_HOUR; + } + /** 今天定时日程是否已全部结束(最晚结束比例 <= 当前时刻比例);只剩全天 / 无定时日程也算已结束。 + * 非今天(r<0)返回 false,交由原条件判断(非今天本来就不显示红线)。 */ + private w6AllTimedEnded(): boolean { + const r: number = this.w6NowR(); + if (r < 0) { + return false; + } + let maxEnd: number = 0; + for (const b of this.parseBlocks()) { + const e: number = b.topRatio + b.heightRatio; + if (e > maxEnd) { + maxEnd = e; + } + } + return r >= maxEnd - 0.0001; + } + /** 红线是否显示(当前时刻落在视窗内 + 今天还有未结束的定时日程) */ + private w6NowVisible(): boolean { + const r: number = this.w6NowR(); + return r >= 0 && !this.w6AllTimedEnded() + && r >= this.w6StartHour() / 24 - 0.0001 && r <= this.w6EndHour() / 24 + 0.0001; + } + /** 红线距内容顶部的偏移(vp) */ + private w6NowPadVp(): number { + const v: number = (this.w6NowR() - this.w6StartHour() / 24) * 24 * W6_HOUR; + return v > 0 ? v : 0; + } + + private parseAllDay(): TAllDay6[] { + try { + return JSON.parse(this.allDayJson) as TAllDay6[]; + } catch (err) { + return []; + } + } + private w6AllDay(): TAllDay6[] { + return this.parseAllDay().slice(0, W6_ALLDAY_MAX); + } + private w6AllDayRest(): number { + const n: number = this.parseAllDay().length - W6_ALLDAY_MAX; + return n > 0 ? n : 0; + } + + private hourLabels(): number[] { + const arr: number[] = []; + for (let h = this.w6StartHour(); h < this.w6EndHour(); h++) { + arr.push(h); + } + return arr; + } + + private hourText(h: number): string { + return h < 10 ? `0${h}:00` : `${h}:00`; + } + + /** 右上角添加按钮:拉起 App 直接进入新建日程页(兄弟节点布局,不冒泡) */ @Builder addButton() { Button() { @@ -56,120 +383,6 @@ struct Widget6x4Card { }) } - /** 全天事件行:圆角矩形 + 左侧日历色竖条 + 标题 + “全天”标记(无时间轴) */ - @Builder - buildAllDayRow(item: CardItem6x4) { - Row({ space: 8 }) { - Column() - .width(3) - .height(16) - .borderRadius(2) - .backgroundColor(item.color) - Text(item.title) - .fontSize(12) - .fontColor('#1A1A1A') - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) - Text('全天') - .fontSize(9) - .fontColor('#FFFFFF') - .backgroundColor(item.color) - .borderRadius(6) - .padding({ left: 5, right: 5, top: 1, bottom: 1 }) - if (item.calName !== '') { - Text(item.calName) - .fontSize(9) - .fontColor(item.color) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .constraintSize({ maxWidth: '25%' }) - } - } - .alignItems(VerticalAlign.Center) - .width('100%') - .padding({ left: 8, right: 8, top: 4, bottom: 4 }) - .borderRadius(8) - .backgroundColor('#F5F7FA') - } - - /** 当前时间红线标记:左侧红点 + 贯穿整行的红色细线 */ - @Builder - nowLine() { - Row() { - Column() - .width(6) - .height(6) - .borderRadius(3) - .backgroundColor('#FF3B30') - Column() - .height(2) - .layoutWeight(1) - .backgroundColor('#FF3B30') - .borderRadius(1) - } - .width('100%') - .padding({ top: 3, bottom: 3 }) - } - - /** 有时间事件行(时间轴):圆角矩形 + 左色竖条 + 开始时间(上)-竖线-结束时间(下) + 标题 */ - @Builder - buildTimedRow(item: CardItem6x4) { - Row({ space: 8 }) { - Column() - .width(3) - .height(38) - .borderRadius(2) - .backgroundColor(item.isNow ? '#FF3B30' : item.color) - // 时间列:开始时间在上、结束时间在下、中间竖线连接 - Column({ space: 2 }) { - Text(item.time) - .fontSize(10) - .fontWeight(FontWeight.Medium) - .fontColor(item.isNow ? '#FF3B30' : '#333333') - Column() - .width(1.5) - .layoutWeight(1) - .backgroundColor('#D8D8D8') - .borderRadius(1) - Text(item.endTime) - .fontSize(10) - .fontColor('#999999') - } - .width(38) - .alignItems(HorizontalAlign.Center) - .height(38) - Text(item.title) - .fontSize(12) - .fontColor(item.isNow ? '#FF3B30' : '#1A1A1A') - .maxLines(2) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) - if (item.isNow) { - Text('● 进行中') - .fontSize(9) - .fontColor('#FF3B30') - .padding({ left: 4, right: 4, top: 1, bottom: 1 }) - .borderRadius(4) - .backgroundColor('#FFECEA') - } - if (item.calName !== '') { - Text(item.calName) - .fontSize(9) - .fontColor(item.color) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .constraintSize({ maxWidth: '25%' }) - } - } - .alignItems(VerticalAlign.Center) - .width('100%') - .padding({ left: 8, right: 8, top: 5, bottom: 5 }) - .borderRadius(8) - .backgroundColor(item.isNow ? '#FFF1F0' : '#F5F7FA') - } - - /** 卡片"打开 App":点击日期区或日程列表触发;添加按钮是 header 行的兄弟节点,其点击不会冒泡到这里 */ private openApp(): void { postCardAction(this, { action: 'router', @@ -178,80 +391,268 @@ struct Widget6x4Card { }); } + /** 翻页:交给 FormExtensionAbility.onFormEvent 重新取数并推送(跨天查看) */ + private page(act: string): void { + postCardAction(this, { + action: 'message', + params: { pageAction: act } + }); + } + + /** 上一天 / 下一天 圆按钮 */ + @Builder + private pageBtn(label: string, act: string) { + Button() { + Text(label) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor('#333333') + } + .width(22) + .height(22) + .borderRadius(11) + .padding(0) + .backgroundColor('#F2F3F5') + .onClick(() => this.page(act)) + } + + /** 非今天时额外给一个"回到今天"按钮 */ + @Builder + private sideBtn() { + if (!this.isToday) { + Button() { + Text('今') + .fontSize(12) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') + } + .width(22) + .height(22) + .borderRadius(11) + .padding(0) + .backgroundColor('#007DFF') + .onClick(() => this.page('today')) + } + } + build() { Column({ space: 6 }) { Row({ space: 6 }) { - Row({ space: 6 }) { + this.pageBtn('‹', 'prev') + Column() { Text(this.dateText) - .fontSize(16) + .fontSize(13) .fontWeight(FontWeight.Bold) - .fontColor('#1A1A1A') + .fontColor(this.isToday ? '#007DFF' : '#1A1A1A') + .maxLines(1) Text(this.lunarText) - .fontSize(12) + .fontSize(10) .fontColor('#8A8A8A') + .maxLines(1) } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) .onClick(() => this.openApp()) - Blank() - Text('同步日历') + Text(`${this.dayCount} 条`) .fontSize(10) .fontColor('#B0B0B0') + this.pageBtn('›', 'next') + this.sideBtn() this.addButton() } .width('100%') Divider().strokeWidth(0.5).color('#E5E5E5') - if (this.parseItems().length === 0) { - Column({ space: 6 }) { - Text('📅') - .fontSize(24) - Text('暂无日程') - .fontSize(13) - .fontColor('#8A8A8A') - } - .width('100%') - .layoutWeight(1) - .justifyContent(FlexAlign.Center) - .onClick(() => this.openApp()) - } else { - List({ space: 4 }) { - ForEach(this.parseItems(), (item: CardItem6x4, idx: number) => { - ListItem() { - Column({ space: 3 }) { - if (item.showDate) { - Text(item.date) - .fontSize(10) - .fontWeight(FontWeight.Bold) - .fontColor('#666666') + // 卡片不支持 Scroll,但支持 List / ListItem: + // 把"整日时间轴"作为**一个很高的 ListItem**,超出卡片窗口的部分靠上下滑动查看 + List() { + ListItem() { + Column() { + // 全天 / 跨天:同样用**色块**展示(与列表视图一致),随内容一起滚动 + if (this.w6AllDay().length > 0) { + Column({ space: 2 }) { + ForEach(this.w6AllDay(), (a: TAllDay6) => { + Row({ space: 5 }) { + Text(a.isAllDay ? '全天' : '跨天') + .fontSize(8) + .fontColor('#FFFFFF') + .backgroundColor('#26000000') + .borderRadius(5) + .padding({ left: 4, right: 4, top: 1, bottom: 1 }) + Text(a.title) + .fontSize(11) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + } + .alignItems(VerticalAlign.Center) + .width('100%') + .height(W6_ALLDAY_H - 2) + .padding({ left: 6, right: 6 }) + .borderRadius(6) + .backgroundColor(a.color) + .onClick(() => this.openApp()) + }, (a: TAllDay6, idx: number) => `ad_${idx}_${a.eventKey}`) + if (this.w6AllDayRest() > 0) { + Text(`还有 ${this.w6AllDayRest()} 个全天日程`) + .fontSize(9) + .fontColor('#8A8A8A') .width('100%') - } - if (item.showNowLine && !item.nowLineBelow) { - this.nowLine() - } - if (item.time === '全天') { - this.buildAllDayRow(item) - } else { - this.buildTimedRow(item) - } - if (item.nowLineBelow) { - this.nowLine() + .padding({ left: 2 }) } } .width('100%') + .padding({ bottom: 2 }) } - }, (item: CardItem6x4, idx: number) => `${idx}_${item.title}_${item.time}`) + + // 时间轴主体:左侧小时刻度(放在 Stack 外,避免被色块盖住)+ 右侧三层堆叠 + Row() { + Column() { + ForEach(this.hourLabels(), (h: number) => { + Text(this.hourText(h)) + .fontSize(9) + .fontColor(this.w6IsNowHour(h) ? '#FF3B30' : '#9AA0A6') + .width(W6_GUTTER) + .height(W6_HOUR) + .textAlign(TextAlign.End) + .padding({ right: 3 }) + .border({ width: { bottom: 0.5 }, color: { bottom: '#14000000' } }) + }, (h: number) => `h${h}`) + } + .width(W6_GUTTER) + .height(this.w6ContentH()) + + Stack() { + this.w6GridLayer() // 第 1 层:整点网格线 + this.w6EventLayer() // 第 2 层:日程色块 + this.w6NowLayer() // 第 3 层:当前时间红线 + } + .layoutWeight(1) + .height(this.w6ContentH()) + .alignContent(Alignment.TopStart) + .border({ width: { left: 0.5 }, color: { left: '#14000000' } }) + } + .width('100%') + .height(this.w6ContentH()) + .alignItems(VerticalAlign.Top) + } + .width('100%') + .height(this.w6ItemH()) } .width('100%') - .layoutWeight(1) - .scrollBar(BarState.Auto) - .cachedCount(12) - .onClick(() => this.openApp()) + .height(this.w6ItemH()) + } + .layoutWeight(1) + .width('100%') + } + .width('100%') + .height('100%') + .padding(12) + .backgroundColor('#FFFFFF') + .borderRadius(16) + } + + /** 第 1 层:整点网格(每小时一格,格底一条灰线) */ + @Builder + private w6GridLayer() { + Column() { + ForEach(this.hourLabels(), (h: number) => { + Column() + .width('100%') + .height(W6_HOUR) + .border({ width: { bottom: 0.5 }, color: { bottom: '#14000000' } }) + .hitTestBehavior(HitTestMode.None) // 装饰层子节点同样不参与命中测试 + }, (h: number) => `gd_${h}`) + } + .width('100%') + .height('100%') + .hitTestBehavior(HitTestMode.None) // 装饰层:不参与命中测试,触碰事件穿透到下层色块 + } + + /** 第 2 层:日程色块(冲突组分行 + 组内 lane 分列) */ + @Builder + private w6EventLayer() { + Column() { + ForEach(this.w6Rows(), (r: T6Row) => { + if (r.kind === 1) { + Row() { + ForEach(r.group.lanes, (lane: TBlock6[], li: number) => { + Column() { + ForEach(lane, (b: TBlock6, index: number) => { + Blank().height(this.w6LanePadVp(r.group, lane, index)) + Column({ space: 1 }) { + Text(b.title) + .fontSize(11) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + if (b.heightRatio * 86400000 >= 60 * 60000) { + Text(b.timeText) + .fontSize(9) + .fontColor('#E6FFFFFF') + .maxLines(1) + .width('100%') + } + } + .alignItems(HorizontalAlign.Start) + .padding({ left: 5, right: 3, top: 1, bottom: 1 }) + .borderRadius(4) + .backgroundColor(b.color) + .opacity(b.isNow ? 1 : 0.92) + .clip(true) + .width('100%') + .height(this.w6BlockVp(b)) + .constraintSize({ minHeight: 16 }) + .onClick(() => this.openApp()) + }, (b: TBlock6) => `b_${r.key}_${b.eventKey}`) + } + .layoutWeight(1) + .height('100%') + .padding({ right: 2 }) + .clip(true) + }, (lane: TBlock6[], li: number) => `ln_${r.key}_${li}`) + } + .width('100%') + .height(this.w6SpanVp(r.from, r.to)) + .alignItems(VerticalAlign.Top) + } else { + Blank().height(this.w6SpanVp(r.from, r.to)) + } + }, (r: T6Row) => r.key) + } + .width('100%') + .height('100%') + } + + /** 第 3 层:当前时间红线(浮在最上层) */ + @Builder + private w6NowLayer() { + Column() { + if (this.w6NowVisible()) { + Blank().height(this.w6NowPadVp()).hitTestBehavior(HitTestMode.None) // 占位块:不拦截点击 + Row() { + Column() + .width(5) + .height(5) + .borderRadius(3) + .backgroundColor('#FF3B30') + Column() + .height(2) + .layoutWeight(1) + .backgroundColor('#FF3B30') + .borderRadius(1) + } + .width('100%') + .hitTestBehavior(HitTestMode.None) // 红线本身也不拦截点击,穿透到色块层 } } .width('100%') .height('100%') - .padding(14) - .backgroundColor('#FFFFFF') - .borderRadius(16) + .hitTestBehavior(HitTestMode.None) // 装饰层:整层不参与命中测试,触碰事件穿透到下层色块 } } diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json index 00d92b3..63b25ad 100644 --- a/entry/src/main/resources/base/element/string.json +++ b/entry/src/main/resources/base/element/string.json @@ -38,7 +38,7 @@ }, { "name": "card_desc", - "value": "展示今天日期与正在进行中的日程" + "value": "按时间轴展示今天的日程" }, { "name": "card_2x2_name", @@ -50,11 +50,11 @@ }, { "name": "card_4x4_name", - "value": "日程列表" + "value": "今日时间轴" }, { "name": "card_6x4_name", - "value": "日程大全" + "value": "全天时间轴" }, { "name": "sync_work_desc",