+2
-2
@@ -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",
|
||||
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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<string> {
|
||||
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<void> {
|
||||
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 时期)落库的残缺数据。
|
||||
|
||||
@@ -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<string, number> = new Map<string, number>();
|
||||
|
||||
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<void> {
|
||||
@@ -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<number, formBindingData.FormBindingData> =
|
||||
new Map<number, formBindingData.FormBindingData>();
|
||||
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<CardData> {
|
||||
/** 构建卡片数据。
|
||||
* @param dayOffset 相对"今天"的天数偏移(卡片翻页用:负数=过去,0=今天,正数=未来)
|
||||
* 非今天时不输出 nowRatio(红线不显示)。 */
|
||||
static async buildCardData(context: common.Context, dayOffset: number = 0): Promise<CardData> {
|
||||
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)}`);
|
||||
}
|
||||
|
||||
@@ -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<number, TimelineBlock[]> = new Map<number, TimelineBlock[]>();
|
||||
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<end),供视图高亮 */
|
||||
static markNow(blocks: TimelineBlock[], nowMs: number): void {
|
||||
for (const b of blocks) {
|
||||
if (b.ev !== null) {
|
||||
b.isNow = b.ev.startTime <= nowMs && nowMs < b.ev.endTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前时间在当天时间轴上的纵向比例(0~1,超出 0 点/24 点会被夹紧) */
|
||||
static nowRatio(nowMs: number): number {
|
||||
const dayStart: number = TimelineUtil.startOfDay(nowMs);
|
||||
let r: number = (nowMs - dayStart) / 86400000;
|
||||
if (r < 0) {
|
||||
r = 0;
|
||||
}
|
||||
if (r > 1) {
|
||||
r = 1;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 当前时间的小时刻度文字(画在红线左侧),如 '14:05' */
|
||||
static nowLabel(ms: number): string {
|
||||
return TimelineUtil.fmtTime(ms);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>('pendingAccountType', type);
|
||||
private openAddPage(): void {
|
||||
// 目前只支持 CalDAV:点 + 直接进入添加 CalDAV 账号页(不再弹类型选择菜单)
|
||||
AppStorage.setOrCreate<string>('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
|
||||
|
||||
+647
-231
File diff suppressed because it is too large
Load Diff
@@ -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<void> {
|
||||
if (this.context === undefined) {
|
||||
private async saveShowSystem(value: boolean): Promise<void> { if (this.context === undefined) {
|
||||
return;
|
||||
}
|
||||
await AppSettings.setShowSystemCalendar(this.context, value);
|
||||
@@ -244,6 +247,22 @@ struct SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
private async saveDefaultView(view: string): Promise<void> {
|
||||
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 }) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
.width(36)
|
||||
.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)
|
||||
.fontSize(13)
|
||||
.fontColor('#1A1A1A')
|
||||
.maxLines(2)
|
||||
.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)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.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}`)
|
||||
|
||||
@@ -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<number, TBlock[]> = new Map<number, TBlock[]>();
|
||||
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,50 +386,260 @@ struct Widget4x4Card {
|
||||
})
|
||||
}
|
||||
|
||||
/** 全天事件行:圆角矩形 + 左侧日历色竖条 + 标题 + “全天”标记(无时间轴) */
|
||||
private openApp(): void {
|
||||
postCardAction(this, {
|
||||
action: 'router',
|
||||
abilityName: 'EntryAbility',
|
||||
params: {}
|
||||
});
|
||||
}
|
||||
|
||||
/** 翻页:交给 FormExtensionAbility.onFormEvent 重新取数并推送(跨天查看) */
|
||||
private page(act: string): void {
|
||||
postCardAction(this, {
|
||||
action: 'message',
|
||||
params: { pageAction: act }
|
||||
});
|
||||
}
|
||||
|
||||
/** 上一天 / 下一天 圆按钮 */
|
||||
@Builder
|
||||
buildAllDayRow(item: CardItem4x4) {
|
||||
Row({ space: 8 }) {
|
||||
Column()
|
||||
.width(3)
|
||||
.height(16)
|
||||
.borderRadius(2)
|
||||
.backgroundColor(item.color)
|
||||
Text(item.title)
|
||||
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)
|
||||
.fontColor('#1A1A1A')
|
||||
.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: 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())
|
||||
this.pageBtn('›', 'next')
|
||||
this.sideBtn()
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Divider().strokeWidth(0.5).color('#E5E5E5')
|
||||
|
||||
// 卡片不支持 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)
|
||||
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')
|
||||
.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%')
|
||||
.padding({ left: 2 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ bottom: 2 })
|
||||
}
|
||||
|
||||
/** 当前时间红线标记:左侧红点 + 贯穿整行的红色细线 */
|
||||
// 时间轴主体:左侧小时刻度(放在 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%')
|
||||
.height(this.w4ItemH())
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding(12)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.borderRadius(16)
|
||||
}
|
||||
|
||||
/** 第 1 层:整点网格(每小时一格,格底一条灰线) */
|
||||
@Builder
|
||||
nowLine() {
|
||||
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(6)
|
||||
.height(6)
|
||||
.width(5)
|
||||
.height(5)
|
||||
.borderRadius(3)
|
||||
.backgroundColor('#FF3B30')
|
||||
Column()
|
||||
@@ -109,149 +649,11 @@ struct Widget4x4Card {
|
||||
.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',
|
||||
abilityName: 'EntryAbility',
|
||||
params: {}
|
||||
});
|
||||
}
|
||||
|
||||
build() {
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 6 }) {
|
||||
Row({ space: 6 }) {
|
||||
Text(this.dateText)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#1A1A1A')
|
||||
Text(this.lunarText)
|
||||
.fontSize(12)
|
||||
.fontColor('#8A8A8A')
|
||||
}
|
||||
.onClick(() => this.openApp())
|
||||
Blank()
|
||||
Text('同步日历')
|
||||
.fontSize(10)
|
||||
.fontColor('#B0B0B0')
|
||||
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: CardItem4x4, idx: number) => {
|
||||
ListItem() {
|
||||
Column({ space: 3 }) {
|
||||
if (item.showDate) {
|
||||
Text(item.date)
|
||||
.fontSize(10)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#666666')
|
||||
.width('100%')
|
||||
}
|
||||
if (item.showNowLine && !item.nowLineBelow) {
|
||||
this.nowLine()
|
||||
}
|
||||
if (item.time === '全天') {
|
||||
this.buildAllDayRow(item)
|
||||
} else {
|
||||
this.buildTimedRow(item)
|
||||
}
|
||||
if (item.nowLineBelow) {
|
||||
this.nowLine()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}, (item: CardItem4x4, idx: number) => `${idx}_${item.title}_${item.time}`)
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.cachedCount(8)
|
||||
.onClick(() => this.openApp())
|
||||
.hitTestBehavior(HitTestMode.None) // 红线本身也不拦截点击,穿透到色块层
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding(14)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.borderRadius(16)
|
||||
.hitTestBehavior(HitTestMode.None) // 装饰层:整层不参与命中测试,触碰事件穿透到下层色块
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<number, TBlock6[]> = new Map<number, TBlock6[]>();
|
||||
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,50 +383,262 @@ struct Widget6x4Card {
|
||||
})
|
||||
}
|
||||
|
||||
/** 全天事件行:圆角矩形 + 左侧日历色竖条 + 标题 + “全天”标记(无时间轴) */
|
||||
private openApp(): void {
|
||||
postCardAction(this, {
|
||||
action: 'router',
|
||||
abilityName: 'EntryAbility',
|
||||
params: {}
|
||||
});
|
||||
}
|
||||
|
||||
/** 翻页:交给 FormExtensionAbility.onFormEvent 重新取数并推送(跨天查看) */
|
||||
private page(act: string): void {
|
||||
postCardAction(this, {
|
||||
action: 'message',
|
||||
params: { pageAction: act }
|
||||
});
|
||||
}
|
||||
|
||||
/** 上一天 / 下一天 圆按钮 */
|
||||
@Builder
|
||||
buildAllDayRow(item: CardItem6x4) {
|
||||
Row({ space: 8 }) {
|
||||
Column()
|
||||
.width(3)
|
||||
.height(16)
|
||||
.borderRadius(2)
|
||||
.backgroundColor(item.color)
|
||||
Text(item.title)
|
||||
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)
|
||||
.fontColor('#1A1A1A')
|
||||
.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 }) {
|
||||
this.pageBtn('‹', 'prev')
|
||||
Column() {
|
||||
Text(this.dateText)
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.isToday ? '#007DFF' : '#1A1A1A')
|
||||
.maxLines(1)
|
||||
Text(this.lunarText)
|
||||
.fontSize(10)
|
||||
.fontColor('#8A8A8A')
|
||||
.maxLines(1)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.onClick(() => this.openApp())
|
||||
Text(`${this.dayCount} 条`)
|
||||
.fontSize(10)
|
||||
.fontColor('#B0B0B0')
|
||||
this.pageBtn('›', 'next')
|
||||
this.sideBtn()
|
||||
this.addButton()
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Divider().strokeWidth(0.5).color('#E5E5E5')
|
||||
|
||||
// 卡片不支持 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)
|
||||
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')
|
||||
.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%')
|
||||
.padding({ left: 2 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ bottom: 2 })
|
||||
}
|
||||
|
||||
/** 当前时间红线标记:左侧红点 + 贯穿整行的红色细线 */
|
||||
// 时间轴主体:左侧小时刻度(放在 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%')
|
||||
.height(this.w6ItemH())
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding(12)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.borderRadius(16)
|
||||
}
|
||||
|
||||
/** 第 1 层:整点网格(每小时一格,格底一条灰线) */
|
||||
@Builder
|
||||
nowLine() {
|
||||
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(6)
|
||||
.height(6)
|
||||
.width(5)
|
||||
.height(5)
|
||||
.borderRadius(3)
|
||||
.backgroundColor('#FF3B30')
|
||||
Column()
|
||||
@@ -109,149 +648,11 @@ struct Widget6x4Card {
|
||||
.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',
|
||||
abilityName: 'EntryAbility',
|
||||
params: {}
|
||||
});
|
||||
}
|
||||
|
||||
build() {
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 6 }) {
|
||||
Row({ space: 6 }) {
|
||||
Text(this.dateText)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#1A1A1A')
|
||||
Text(this.lunarText)
|
||||
.fontSize(12)
|
||||
.fontColor('#8A8A8A')
|
||||
}
|
||||
.onClick(() => this.openApp())
|
||||
Blank()
|
||||
Text('同步日历')
|
||||
.fontSize(10)
|
||||
.fontColor('#B0B0B0')
|
||||
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')
|
||||
.width('100%')
|
||||
}
|
||||
if (item.showNowLine && !item.nowLineBelow) {
|
||||
this.nowLine()
|
||||
}
|
||||
if (item.time === '全天') {
|
||||
this.buildAllDayRow(item)
|
||||
} else {
|
||||
this.buildTimedRow(item)
|
||||
}
|
||||
if (item.nowLineBelow) {
|
||||
this.nowLine()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}, (item: CardItem6x4, idx: number) => `${idx}_${item.title}_${item.time}`)
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.cachedCount(12)
|
||||
.onClick(() => this.openApp())
|
||||
.hitTestBehavior(HitTestMode.None) // 红线本身也不拦截点击,穿透到色块层
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding(14)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.borderRadius(16)
|
||||
.hitTestBehavior(HitTestMode.None) // 装饰层:整层不参与命中测试,触碰事件穿透到下层色块
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user