From c0671cccd9d5c44bb753aaeef7fb753b3884656b Mon Sep 17 00:00:00 2001 From: white-yj8109 <19310195525@163.com> Date: Sat, 24 Jan 2026 16:13:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD=EF=BC=9A?= =?UTF-8?q?=E4=B9=A0=E6=83=AF=E6=89=93=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/NotesDatabaseHelper.java | 19 +- src/ui/AlarmAlertActivity.java | 209 +++++++++- src/ui/NoteEditActivity.java | 642 +++++++++++++++++++++++++++++- src/ui/NoteItemData.java | 12 +- 4 files changed, 867 insertions(+), 15 deletions(-) diff --git a/src/data/NotesDatabaseHelper.java b/src/data/NotesDatabaseHelper.java index e1e055f..12b1b3e 100644 --- a/src/data/NotesDatabaseHelper.java +++ b/src/data/NotesDatabaseHelper.java @@ -35,7 +35,7 @@ import net.micode.notes.data.Notes.NoteColumns; public class NotesDatabaseHelper extends SQLiteOpenHelper { private static final String DB_NAME = "note.db"; - private static final int DB_VERSION = 4; + private static final int DB_VERSION = 5; public interface TABLE { public static final String NOTE = "note"; @@ -68,7 +68,9 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { NoteColumns.LOCAL_MODIFIED + " INTEGER NOT NULL DEFAULT 0," + NoteColumns.ORIGIN_PARENT_ID + " INTEGER NOT NULL DEFAULT 0," + NoteColumns.GTASK_ID + " TEXT NOT NULL DEFAULT ''," + - NoteColumns.VERSION + " INTEGER NOT NULL DEFAULT 0" + + NoteColumns.VERSION + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.IS_HABIT + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.HABIT_CONFIG + " TEXT NOT NULL DEFAULT ''" + ")"; /**创建data表的sql语句 @@ -404,6 +406,11 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { oldVersion++; } + if (oldVersion == 4) { + upgradeToV5(db); + oldVersion++; + } + if (reCreateTriggers) { reCreateNoteTableTriggers(db); reCreateDataTableTriggers(db); @@ -444,4 +451,12 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.VERSION + " INTEGER NOT NULL DEFAULT 0"); } + + //更新到版本5:增加习惯打卡支持字段 + private void upgradeToV5(SQLiteDatabase db) { + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.IS_HABIT + + " INTEGER NOT NULL DEFAULT 0"); + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.HABIT_CONFIG + + " TEXT NOT NULL DEFAULT ''"); + } } diff --git a/src/ui/AlarmAlertActivity.java b/src/ui/AlarmAlertActivity.java index 6e80b45..11798f8 100644 --- a/src/ui/AlarmAlertActivity.java +++ b/src/ui/AlarmAlertActivity.java @@ -18,11 +18,16 @@ package net.micode.notes.ui; import android.app.Activity; import android.app.AlertDialog; +import android.app.PendingIntent; +import android.app.AlarmManager; +import android.content.ContentUris; +import android.content.ContentValues; import android.content.Context; import android.content.DialogInterface; import android.content.DialogInterface.OnClickListener; import android.content.DialogInterface.OnDismissListener; import android.content.Intent; +import android.database.Cursor; import android.media.AudioManager; import android.media.MediaPlayer; import android.media.RingtoneManager; @@ -30,17 +35,29 @@ import android.net.Uri; import android.os.Bundle; import android.os.PowerManager; import android.provider.Settings; +import android.util.Log; import android.view.Window; import android.view.WindowManager; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; import net.micode.notes.R; import net.micode.notes.data.Notes; import net.micode.notes.tool.DataUtils; import java.io.IOException; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import java.util.Calendar; //继承Activity //承诺实现click,dismiss接口,不继承功能 public class AlarmAlertActivity extends Activity implements OnClickListener, OnDismissListener { + private static final String TAG = "AlarmAlertActivity"; private long mNoteId; private String mSnippet; private static final int SNIPPET_PREW_MAX_LEN = 60; @@ -83,7 +100,12 @@ public class AlarmAlertActivity extends Activity implements OnClickListener, OnD mPlayer = new MediaPlayer(); //如果不在回收站就显示文字并发出声音 if (DataUtils.visibleInNoteDatabase(getContentResolver(), mNoteId, Notes.TYPE_NOTE)) { - showActionDialog(); + int isHabit = intent.getIntExtra("habit_alarm", 0); + if (isHabit == 1) { + showHabitDialog(intent); + } else { + showActionDialog(); + } playAlarmSound(); } else { finish(); @@ -134,11 +156,190 @@ public class AlarmAlertActivity extends Activity implements OnClickListener, OnD dialog.setMessage(mSnippet); dialog.setPositiveButton(R.string.notealert_ok, this); //setPositive/Negative/NeutralButton()设置:确定,取消,中立按钮; if (isScreenOn()) { - dialog.setNegativeButton(R.string.notealert_enter, this); //取消 + dialog.setNegativeButton(R.string.notealert_enter, this); } - dialog.show().setOnDismissListener(this); //设置关闭监听 + dialog.show().setOnDismissListener(this); } - //选择negative时执行跳转到笔记 (那可能positive仅关闭弹窗) + + // Habit specific dialog with actions: complete, snooze, skip, abandon + private void showHabitDialog(Intent intent) { + View v = getLayoutInflater().inflate(R.layout.habit_alert_dialog, null); + TextView tvTitle = (TextView) v.findViewById(R.id.habit_alert_title); + TextView tvSnippet = (TextView) v.findViewById(R.id.habit_alert_snippet); + final Button btnComplete = (Button) v.findViewById(R.id.habit_btn_complete); + final Button btnSnooze10 = (Button) v.findViewById(R.id.habit_btn_snooze10); + final Button btnSnooze30 = (Button) v.findViewById(R.id.habit_btn_snooze30); + final Button btnSkip = (Button) v.findViewById(R.id.habit_btn_skip); + final Button btnAbandon = (Button) v.findViewById(R.id.habit_btn_abandon); + + tvTitle.setText(getString(R.string.app_name)); + tvSnippet.setText(mSnippet); + + final AlertDialog d = new AlertDialog.Builder(this) + .setView(v) + .create(); + + btnComplete.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + recordHabitHistory(mNoteId, "completed", ""); + Toast.makeText(AlarmAlertActivity.this, R.string.habit_record_complete, Toast.LENGTH_SHORT).show(); + d.dismiss(); + } + }); + + btnSnooze10.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + scheduleSnooze(mNoteId, 10); + Toast.makeText(AlarmAlertActivity.this, R.string.habit_snoozed, Toast.LENGTH_SHORT).show(); + d.dismiss(); + } + }); + + btnSnooze30.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + scheduleSnooze(mNoteId, 30); + Toast.makeText(AlarmAlertActivity.this, R.string.habit_snoozed, Toast.LENGTH_SHORT).show(); + d.dismiss(); + } + }); + + btnSkip.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + showSkipReasonDialog(); + // 不要立即关闭对话框,让用户选择原因 + } + }); + + btnAbandon.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + abandonHabit(mNoteId); + Toast.makeText(AlarmAlertActivity.this, R.string.habit_abandoned, Toast.LENGTH_SHORT).show(); + d.dismiss(); + } + }); + + d.setOnDismissListener(this); + d.show(); + } + + private void showSkipReasonDialog() { + final String[] reasons = new String[] { getString(R.string.skip_reason_busy), + getString(R.string.skip_reason_sick), getString(R.string.skip_reason_other) }; + AlertDialog.Builder b = new AlertDialog.Builder(this); + b.setTitle(R.string.habit_skip_title); + b.setItems(reasons, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + String reason = reasons[which]; + recordHabitHistory(mNoteId, "skipped", reason); + Toast.makeText(AlarmAlertActivity.this, R.string.habit_record_skipped, Toast.LENGTH_SHORT).show(); + // 选择原因后关闭主对话框 + dialog.dismiss(); + finish(); + } + }); + b.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }); + b.show(); + } + + private void scheduleSnooze(long noteId, int minutes) { + try { + Intent intent = new Intent(this, AlarmReceiver.class); + intent.setData(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId)); + intent.putExtra("habit_alarm", 1); + int req = (int) (noteId ^ 0x100000) + minutes; // unique-ish + PendingIntent pi = PendingIntent.getBroadcast(this, req, intent, 0); + AlarmManager am = (AlarmManager) getSystemService(ALARM_SERVICE); + long trigger = System.currentTimeMillis() + minutes * 60 * 1000L; + am.set(AlarmManager.RTC_WAKEUP, trigger, pi); + } catch (Exception e) { + Log.e(TAG, "Schedule snooze error", e); + } + } + + private void abandonHabit(long noteId) { + try { + ContentValues values = new ContentValues(); + values.put(Notes.NoteColumns.IS_HABIT, 0); + values.put(Notes.NoteColumns.HABIT_CONFIG, ""); + getContentResolver().update(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId), values, null, null); + // cancel repeating alarm + Intent intent = new Intent(this, AlarmReceiver.class); + intent.setData(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId)); + intent.putExtra("habit_alarm", 1); + PendingIntent pi = PendingIntent.getBroadcast(this, 0, intent, 0); + AlarmManager am = (AlarmManager) getSystemService(ALARM_SERVICE); + am.cancel(pi); + } catch (Exception e) { + Log.e(TAG, "Abandon habit error", e); + } + } + + // Record history into habit_config.history (append object {date,status,reason}) + private void recordHabitHistory(long noteId, String status, String reason) { + try { + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId); + Cursor c = getContentResolver().query(uri, new String[]{Notes.NoteColumns.HABIT_CONFIG}, null, null, null); + String cfg = ""; + if (c != null) { + if (c.moveToFirst()) cfg = c.getString(0); + c.close(); + } + JSONObject jo = cfg != null && cfg.length() > 0 ? new JSONObject(cfg) : new JSONObject(); + JSONArray history = jo.has("history") ? jo.getJSONArray("history") : new JSONArray(); + + // 获取当前时间戳 + long recordTime = System.currentTimeMillis(); + + // 创建新记录 + JSONObject newEntry = new JSONObject(); + newEntry.put("date", recordTime); + newEntry.put("status", status); + newEntry.put("reason", reason == null ? "" : reason); + + // 检查是否已经存在该日期的记录,如果有则替换 + java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()); + String recordKey = sdf.format(new java.util.Date(recordTime)); + boolean foundRecord = false; + + for (int i = 0; i < history.length(); i++) { + JSONObject entry = history.getJSONObject(i); + long entryDate = entry.optLong("date", 0); + String entryKey = sdf.format(new java.util.Date(entryDate)); + if (recordKey.equals(entryKey)) { + // 替换该日期的记录 + history.put(i, newEntry); + foundRecord = true; + break; + } + } + + // 如果没有该日期的记录,添加新记录 + if (!foundRecord) { + history.put(newEntry); + } + + jo.put("history", history); + ContentValues values = new ContentValues(); + values.put(Notes.NoteColumns.HABIT_CONFIG, jo.toString()); + getContentResolver().update(uri, values, null, null); + // 通知数据变化,以便日历视图刷新 + getContentResolver().notifyChange(uri, null); + } catch (JSONException e) { + Log.e(TAG, "Record habit history json error", e); + } + } + public void onClick(DialogInterface dialog, int which) { switch (which) { case DialogInterface.BUTTON_NEGATIVE: diff --git a/src/ui/NoteEditActivity.java b/src/ui/NoteEditActivity.java index b9ac0ad..945ec2d 100644 --- a/src/ui/NoteEditActivity.java +++ b/src/ui/NoteEditActivity.java @@ -23,10 +23,14 @@ import android.app.PendingIntent; import android.app.SearchManager; import android.appwidget.AppWidgetManager; import android.content.ContentUris; +import android.content.ContentValues; import android.content.Context; +import android.database.Cursor; +import android.net.Uri; import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; +import android.graphics.Color; import android.graphics.Paint; import android.os.Bundle; import android.preference.PreferenceManager; @@ -53,10 +57,12 @@ import android.view.WindowManager; import android.widget.CheckBox; import android.widget.CompoundButton; import android.widget.CompoundButton.OnCheckedChangeListener; +import android.widget.AdapterView; import android.widget.EditText; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; +import android.widget.TimePicker; import android.widget.Toast; import net.micode.notes.R; @@ -78,22 +84,236 @@ import java.util.HashSet; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import android.widget.Button; +import android.widget.CompoundButton; +import android.widget.Spinner; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import java.util.Calendar; +import java.util.Date; +import android.app.PendingIntent; +import android.app.AlarmManager; +import android.widget.GridLayout; +import android.widget.ProgressBar; +import android.widget.LinearLayout; +import java.text.SimpleDateFormat; +import java.util.Locale; +import java.util.TimeZone; +import java.util.Map; +import java.util.HashMap; public class NoteEditActivity extends Activity implements OnClickListener, - NoteSettingChangedListener, OnTextViewChangeListener,OnSelectionChangeListener { - private class HeadViewHolder {//头视图持有者类??? - public TextView tvModified;//文本修改 - - public ImageView ivAlertIcon;//图像提醒按钮? + + NoteSettingChangedListener, OnTextViewChangeListener, OnSelectionChangeListener { + private class HeadViewHolder { + public TextView tvModified; + public ImageView ivAlertIcon; public TextView tvAlertDate;//文本提醒日期? - public ImageView ibSetBgColor;//设置背景颜色图像视图 public TextView tvCharNum;//新增字符数显示控件 } - private static final Map sBgSelectorBtnsMap = new HashMap();//背景颜色选择按钮映射 + // 显示习惯配置对话框(简单表单),将结果保存为 JSON 到 habit_config + private void showHabitConfigDialog() { + LayoutInflater inflater = LayoutInflater.from(this); + View view = inflater.inflate(R.layout.habit_config_dialog, null); + final Spinner spinnerPeriod = (Spinner) view.findViewById(R.id.spinner_period); + final TimePicker tpRemindTime = (TimePicker) view.findViewById(R.id.tp_remind_time); + final Spinner spinnerTargetType = (Spinner) view.findViewById(R.id.spinner_target_type); + final EditText etTargetValue = (EditText) view.findViewById(R.id.et_target_value); + final LinearLayout llWeeklyTimes = (LinearLayout) view.findViewById(R.id.ll_weekly_times); + final EditText etWeeklyTimes = (EditText) view.findViewById(R.id.et_weekly_times); + + // 设置 TimePicker 为 24 小时制 + tpRemindTime.setIs24HourView(true); + + // setup spinners + ArrayAdapter periodAdapter = ArrayAdapter.createFromResource(this, + R.array.habit_period_options, android.R.layout.simple_spinner_item); + periodAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinnerPeriod.setAdapter(periodAdapter); + + ArrayAdapter targetAdapter = ArrayAdapter.createFromResource(this, + R.array.habit_target_type_options, android.R.layout.simple_spinner_item); + targetAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinnerTargetType.setAdapter(targetAdapter); + + // 当选择周期变化时,显示/隐藏每周次数设置 + spinnerPeriod.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + String period = spinnerPeriod.getSelectedItem().toString(); + if (period.equals("每周 X 次")) { + llWeeklyTimes.setVisibility(View.VISIBLE); + } else { + llWeeklyTimes.setVisibility(View.GONE); + } + } + + @Override + public void onNothingSelected(AdapterView parent) { + } + }); + + // prefill if existing + try { + String cfg = mWorkingNote.getHabitConfig(); + if (cfg != null && cfg.length() > 0) { + JSONObject jo = new JSONObject(cfg); + String period = jo.optString("period", "每日"); + spinnerPeriod.setSelection(periodAdapter.getPosition(period)); + + // 设置提醒时间 + String remindTime = jo.optString("remind_time", "08:00"); + if (!remindTime.isEmpty()) { + String[] parts = remindTime.split(":"); + int hour = Integer.parseInt(parts[0]); + int minute = Integer.parseInt(parts[1]); + tpRemindTime.setHour(hour); + tpRemindTime.setMinute(minute); + } + + // 设置每周次数 + if (period.equals("每周 X 次")) { + llWeeklyTimes.setVisibility(View.VISIBLE); + etWeeklyTimes.setText(String.valueOf(jo.optInt("weekly_times", 3))); + } + + spinnerTargetType.setSelection(targetAdapter.getPosition(jo.optString("target_type", "连续天数"))); + etTargetValue.setText(String.valueOf(jo.optInt("target_value", 0))); + } + } catch (Exception e) { + // ignore + } + + AlertDialog.Builder b = new AlertDialog.Builder(this); + b.setTitle(R.string.habit_config_title); + b.setView(view); + b.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + String period = spinnerPeriod.getSelectedItem().toString(); + + // 获取提醒时间 + int hour = tpRemindTime.getHour(); + int minute = tpRemindTime.getMinute(); + String remind = String.format("%02d:%02d", hour, minute); + + String targetType = spinnerTargetType.getSelectedItem().toString(); + int targetValue = 0; + try { + targetValue = Integer.parseInt(etTargetValue.getText().toString()); + } catch (Exception e) { + targetValue = 0; + } + + // 获取每周次数 + int weeklyTimes = 3; + if (period.equals("每周 X 次")) { + try { + weeklyTimes = Integer.parseInt(etWeeklyTimes.getText().toString()); + } catch (Exception e) { + weeklyTimes = 3; + } + } + + JSONObject jo = new JSONObject(); + try { + jo.put("period", period); + jo.put("remind_time", remind); + jo.put("target_type", targetType); + jo.put("target_value", targetValue); + jo.put("weekly_times", weeklyTimes); + } catch (JSONException e) { + // ignore + } + if (mWorkingNote != null) { + mWorkingNote.setHabit(true, jo.toString()); + // schedule alarm according to remind_time + scheduleHabitAlarm(mWorkingNote); + // refresh the menu to update the habit settings button + invalidateOptionsMenu(); + } + } + }); + b.setNegativeButton(android.R.string.cancel, null); + b.show(); + } + + // Schedule or cancel habit alarm according to current WorkingNote.habit_config + private void scheduleHabitAlarm(WorkingNote note) { + if (note == null || !note.isHabit()) { + // cancel any existing alarm + cancelHabitAlarm(note); + return; + } + String cfg = note.getHabitConfig(); + if (cfg == null || cfg.length() == 0) { + return; + } + try { + JSONObject jo = new JSONObject(cfg); + String remind = jo.optString("remind_time", ""); + if (remind == null || remind.length() == 0) { + // no remind time provided + return; + } + String[] parts = remind.split(":"); + int hour = Integer.parseInt(parts[0]); + int minute = Integer.parseInt(parts[1]); + + // ensure note saved and has id + if (!note.existInDatabase()) { + note.saveNote(); + } + if (!note.existInDatabase()) return; + + long noteId = note.getNoteId(); + Intent intent = new Intent(this, AlarmReceiver.class); + intent.setData(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId)); + intent.putExtra("habit_alarm", 1); + PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 0, intent, 0); + AlarmManager alarmManager = ((AlarmManager) getSystemService(ALARM_SERVICE)); + + Calendar c = Calendar.getInstance(); + c.set(Calendar.HOUR_OF_DAY, hour); + c.set(Calendar.MINUTE, minute); + c.set(Calendar.SECOND, 0); + long trigger = c.getTimeInMillis(); + long now = System.currentTimeMillis(); + if (trigger <= now) { + // schedule for next day + trigger += AlarmManager.INTERVAL_DAY; + } + + alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, trigger, + AlarmManager.INTERVAL_DAY, pendingIntent); + } catch (Exception e) { + Log.e(TAG, "Schedule habit alarm error", e); + } + } + + private void cancelHabitAlarm(WorkingNote note) { + try { + if (note == null || !note.existInDatabase()) return; + long noteId = note.getNoteId(); + Intent intent = new Intent(this, AlarmReceiver.class); + intent.setData(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId)); + intent.putExtra("habit_alarm", 1); + PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 0, intent, 0); + AlarmManager alarmManager = ((AlarmManager) getSystemService(ALARM_SERVICE)); + alarmManager.cancel(pendingIntent); + } catch (Exception e) { + Log.e(TAG, "Cancel habit alarm error", e); + } + } + + private static final Map sBgSelectorBtnsMap = new HashMap(); static { sBgSelectorBtnsMap.put(R.id.iv_bg_yellow, ResourceParser.YELLOW); sBgSelectorBtnsMap.put(R.id.iv_bg_red, ResourceParser.RED); @@ -143,6 +363,20 @@ public class NoteEditActivity extends Activity implements OnClickListener, private WorkingNote mWorkingNote; + // habit calendar UI + private LinearLayout mHabitPanel; + private Button mBtnPrevMonth; + private Button mBtnNextMonth; + private TextView mTvHabitMonth; + private GridLayout mHabitCalendarGrid; + private TextView mTvHabitTotal; + private ProgressBar mProgressHabitGoal; + + // calendar state + private Calendar mRenderCalendar = Calendar.getInstance(); + + // habit ui + private SharedPreferences mSharedPrefs; private int mFontSizeId; @@ -330,9 +564,341 @@ public class NoteEditActivity extends Activity implements OnClickListener, * is not ready */ showAlertHeader(); + // render habit calendar if this note is a habit + if (mWorkingNote.isHabit()) { + mHabitPanel.setVisibility(View.VISIBLE); + // ensure calendar shows current month + mRenderCalendar = Calendar.getInstance(); + renderHabitPanel(); + } else { + mHabitPanel.setVisibility(View.GONE); + } } - private void showAlertHeader() {//显示笔记的 闹钟提醒 信息 + // Render calendar and statistics from habit_config.history + private void renderHabitPanel() { + if (mWorkingNote == null || !mWorkingNote.isHabit()) return; + Map dayStatus = new HashMap(); // yyyy-MM-dd -> status + int totalCompleted = 0; + + // 优先使用 WorkingNote 中的 habit_config,避免日历点击改状态后 DB 缓存导致视图不刷新 + String cfg = (mWorkingNote != null) ? mWorkingNote.getHabitConfig() : null; + if (cfg == null || cfg.length() == 0) { + if (mWorkingNote != null && mWorkingNote.existInDatabase()) { + try { + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, mWorkingNote.getNoteId()); + Cursor c = getContentResolver().query(uri, new String[]{Notes.NoteColumns.HABIT_CONFIG}, null, null, null); + if (c != null) { + if (c.moveToFirst()) { + String fromDb = c.getString(0); + if (fromDb != null && fromDb.length() > 0) cfg = fromDb; + } + c.close(); + } + } catch (Exception e) { + Log.e(TAG, "Failed to read latest habit config", e); + } + } + } + if (cfg == null) cfg = ""; + + try { + if (cfg != null && cfg.length() > 0) { + JSONObject jo = new JSONObject(cfg); + if (jo.has("history")) { + JSONArray history = jo.getJSONArray("history"); + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()); + for (int i = 0; i < history.length(); i++) { + JSONObject e = history.getJSONObject(i); + long date = e.optLong("date", 0); + String status = e.optString("status", ""); + if (date > 0) { + Calendar c = Calendar.getInstance(); + c.setTimeInMillis(date); + String key = sdf.format(c.getTime()); + dayStatus.put(key, status); + if ("completed".equals(status)) totalCompleted++; + } + } + } + } + } catch (Exception e) { + // ignore parse errors + Log.e(TAG, "Failed to parse habit config", e); + } + + // prepare month display + Calendar cal = (Calendar) mRenderCalendar.clone(); + cal.set(Calendar.DAY_OF_MONTH, 1); + int month = cal.get(Calendar.MONTH); + int year = cal.get(Calendar.YEAR); + SimpleDateFormat monthFmt = new SimpleDateFormat("yyyy年MM月", Locale.getDefault()); + mTvHabitMonth.setText(monthFmt.format(cal.getTime())); + + // clear grid + mHabitCalendarGrid.removeAllViews(); + + // add weekday headers + String[] w = new String[] {"日","一","二","三","四","五","六"}; + for (int i = 0; i < 7; i++) { + TextView tv = new TextView(this); + tv.setText(w[i]); + tv.setGravity(android.view.Gravity.CENTER); + tv.setPadding(6,6,6,6); + mHabitCalendarGrid.addView(tv); + } + + int firstWeekday = cal.get(Calendar.DAY_OF_WEEK) - 1; // 0..6 + int daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH); + + // fill blanks + for (int i = 0; i < firstWeekday; i++) { + TextView tv = new TextView(this); + mHabitCalendarGrid.addView(tv); + } + + SimpleDateFormat sdfKey = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()); + for (int d = 1; d <= daysInMonth; d++) { + cal.set(Calendar.DAY_OF_MONTH, d); + String key = sdfKey.format(cal.getTime()); + TextView cell = new TextView(this); + cell.setText(String.valueOf(d)); + cell.setGravity(android.view.Gravity.CENTER); + cell.setPadding(12,12,12,12); + String status = dayStatus.get(key); + String icon = ""; + boolean isToday = isSameDay(cal, Calendar.getInstance()); + if (status != null) { + if ("completed".equals(status)) { + cell.setBackgroundResource(isToday ? R.drawable.habit_day_today_bg : R.drawable.habit_day_completed_bg); + icon = "✅ "; + } else if ("skipped".equals(status)) { + cell.setBackgroundResource(isToday ? R.drawable.habit_day_today_bg : R.drawable.habit_day_skipped_bg); + icon = "➖ "; + } else { + cell.setBackgroundResource(isToday ? R.drawable.habit_day_today_bg : R.drawable.habit_day_pending_bg); + icon = "🔄 "; + } + } else { + // future or empty + if (isToday) { + cell.setBackgroundResource(R.drawable.habit_day_today_bg); + icon = "🔄 "; + } + } + // 设置文本为图标+日期 + cell.setText(icon + String.valueOf(d)); + final String selKey = key; + final Calendar clickedCal = (Calendar) cal.clone(); + final String cellStatus = status; + cell.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + // 检查点击的日期是否是过去的日期或今天 + Calendar today = Calendar.getInstance(); + today.set(Calendar.HOUR_OF_DAY, 0); + today.set(Calendar.MINUTE, 0); + today.set(Calendar.SECOND, 0); + today.set(Calendar.MILLISECOND, 0); + + if (clickedCal.before(today) || isSameDay(clickedCal, today)) { + // 过去的日期或今天,显示三个选项 + AlertDialog.Builder builder = new AlertDialog.Builder(NoteEditActivity.this); + builder.setTitle("设置打卡状态"); + builder.setItems(new CharSequence[]{"设为✅ 已完成", "设为➖ 跳过", "设为🔄 待打卡"}, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + String status = ""; + String reason = ""; + + switch (which) { + case 0: + // 设为已完成 + status = "completed"; + reason = clickedCal.before(today) ? "补打卡" : ""; + break; + case 1: + // 设为跳过 + status = "skipped"; + reason = "手动设置跳过"; + break; + case 2: + // 设为待打卡 + status = "pending"; + reason = "手动设置待打卡"; + break; + } + + // 记录点击日期的打卡状态,传入点击的Calendar对象 + recordHabitHistory(mWorkingNote.getNoteId(), status, reason, clickedCal); + renderHabitPanel(); + } + }); + builder.show(); + } else { + // 未来日期,不允许打卡 + Toast.makeText(NoteEditActivity.this, "未来日期暂不允许设置状态", Toast.LENGTH_SHORT).show(); + } + } + }); + mHabitCalendarGrid.addView(cell); + } + + int goal = 0; + try { if (cfg != null && cfg.length() > 0) { JSONObject j = new JSONObject(cfg); goal = j.optInt("target_value", 0); } } catch (Exception e) {} + + mTvHabitTotal.setText("总计" + totalCompleted + "天"); + if (goal > 0) { + int prog = Math.min(100, (int) ((totalCompleted * 100L) / goal)); + mProgressHabitGoal.setProgress(prog); + } else { + mProgressHabitGoal.setProgress(0); + } + } + + private boolean isSameDay(Calendar a, Calendar b) { + return a.get(Calendar.YEAR) == b.get(Calendar.YEAR) && a.get(Calendar.DAY_OF_YEAR) == b.get(Calendar.DAY_OF_YEAR); + } + + // 计算完成的天数 + private int calculateCompletedDays(JSONArray history) { + int count = 0; + try { + Log.d(TAG, "Calculating completed days from history with length: " + history.length()); + for (int i = 0; i < history.length(); i++) { + JSONObject entry = history.getJSONObject(i); + String status = entry.optString("status", ""); + long date = entry.optLong("date", 0); + Log.d(TAG, "History entry " + i + ": date=" + date + ", status=" + status); + if ("completed".equals(status)) { + count++; + } + } + Log.d(TAG, "Calculated completed days: " + count); + } catch (JSONException e) { + Log.e(TAG, "Calculate completed days error", e); + } + return count; + } + + // 检查并显示目标达成弹窗 + private void checkAndShowGoalAchievement(JSONObject config, int completedBefore, int completedAfter) { + try { + int goal = config.optInt("target_value", 0); + Log.d(TAG, "Goal achievement check - completedBefore: " + completedBefore + ", completedAfter: " + completedAfter + ", goal: " + goal); + if (goal > 0) { + // 检查是否刚刚达成目标(更新后达到或超过目标,更新前未达到) + boolean shouldShowDialog = completedAfter >= goal && completedBefore < goal; + Log.d(TAG, "Should show celebration dialog: " + shouldShowDialog); + if (shouldShowDialog) { + // 显示喝彩弹窗 + showCelebrationDialog(); + } + } + } catch (Exception e) { + Log.e(TAG, "Check goal achievement error", e); + } + } + + // 显示喝彩弹窗 + private void showCelebrationDialog() { + // 确保Activity处于可见状态,避免Window handle错误 + if (isFinishing() || isDestroyed()) { + Log.d(TAG, "Activity is not visible, skipping celebration dialog"); + return; + } + + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("🎉 恭喜你!"); + builder.setMessage("你已经达成了习惯目标!继续保持,加油!"); + builder.setPositiveButton("确定", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }); + builder.setCancelable(true); + AlertDialog dialog = builder.create(); + dialog.show(); + } + + // Record history into habit_config.history (same logic as AlarmAlertActivity) + private void recordHabitHistory(long noteId, String status, String reason, Calendar recordCal) { + try { + Uri uri = ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId); + Cursor c = getContentResolver().query(uri, new String[]{Notes.NoteColumns.HABIT_CONFIG}, null, null, null); + String cfg = ""; + if (c != null) { + if (c.moveToFirst()) cfg = c.getString(0); + c.close(); + } + JSONObject jo = cfg != null && cfg.length() > 0 ? new JSONObject(cfg) : new JSONObject(); + JSONArray history = jo.has("history") ? jo.getJSONArray("history") : new JSONArray(); + + // 计算更新前的完成天数 + int completedBefore = calculateCompletedDays(history); + + // 获取点击的日期的时间戳 + long recordTime = recordCal.getTimeInMillis(); + + // 创建新记录 + JSONObject newEntry = new JSONObject(); + newEntry.put("date", recordTime); + newEntry.put("status", status); + newEntry.put("reason", reason == null ? "" : reason); + + // 检查是否已经存在该日期的记录,如果有则替换 + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()); + String recordKey = sdf.format(new Date(recordTime)); + boolean foundRecord = false; + + for (int i = 0; i < history.length(); i++) { + JSONObject entry = history.getJSONObject(i); + long entryDate = entry.optLong("date", 0); + String entryKey = sdf.format(new Date(entryDate)); + if (recordKey.equals(entryKey)) { + // 替换该日期的记录 + history.put(i, newEntry); + foundRecord = true; + break; + } + } + + // 如果没有该日期的记录,添加新记录 + if (!foundRecord) { + history.put(newEntry); + } + + // 计算更新后的完成天数 + int completedAfter = calculateCompletedDays(history); + + jo.put("history", history); + String updatedConfig = jo.toString(); + + // 更新数据库 + ContentValues values = new ContentValues(); + values.put(Notes.NoteColumns.HABIT_CONFIG, updatedConfig); + getContentResolver().update(uri, values, null, null); + getContentResolver().notifyChange(uri, null); + + // 同时更新本地 WorkingNote 缓存,确保 renderHabitPanel 使用最新数据、日历视图立即刷新 + if (mWorkingNote != null) { + mWorkingNote.setHabit(true, updatedConfig); + } + + // 检查是否达成目标 + checkAndShowGoalAchievement(jo, completedBefore, completedAfter); + } catch (JSONException e) { + Log.e(TAG, "Record habit history json error", e); + } + } + + // 兼容原有调用的重载方法 + private void recordHabitHistory(long noteId, String status, String reason) { + recordHabitHistory(noteId, status, reason, Calendar.getInstance()); + } + + private void showAlertHeader() { if (mWorkingNote.hasClockAlert()) { long time = System.currentTimeMillis(); if (time > mWorkingNote.getAlertDate()) { @@ -453,6 +1019,29 @@ public class NoteEditActivity extends Activity implements OnClickListener, mFontSizeId = ResourceParser.BG_DEFAULT_FONT_SIZE; } mEditTextList = (LinearLayout) findViewById(R.id.note_edit_list); + // habit UI binds + mHabitPanel = (LinearLayout) findViewById(R.id.habit_panel); + mBtnPrevMonth = (Button) findViewById(R.id.btn_prev_month); + mBtnNextMonth = (Button) findViewById(R.id.btn_next_month); + mTvHabitMonth = (TextView) findViewById(R.id.tv_habit_month); + mHabitCalendarGrid = (GridLayout) findViewById(R.id.habit_calendar_grid); + mTvHabitTotal = (TextView) findViewById(R.id.tv_habit_total); + mProgressHabitGoal = (ProgressBar) findViewById(R.id.progress_habit_goal); + + mBtnPrevMonth.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + mRenderCalendar.add(Calendar.MONTH, -1); + renderHabitPanel(); + } + }); + mBtnNextMonth.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + mRenderCalendar.add(Calendar.MONTH, 1); + renderHabitPanel(); + } + }); } @Override @@ -586,6 +1175,11 @@ public class NoteEditActivity extends Activity implements OnClickListener, } else { menu.findItem(R.id.menu_delete_remind).setVisible(false); } + // 习惯相关菜单处理 + boolean isHabit = mWorkingNote.isHabit(); + menu.findItem(R.id.menu_set_habit).setVisible(!isHabit); + menu.findItem(R.id.menu_habit_settings).setVisible(isHabit); + menu.findItem(R.id.menu_stop_habit).setVisible(isHabit); return true; } @@ -634,6 +1228,38 @@ public class NoteEditActivity extends Activity implements OnClickListener, case R.id.menu_delete_remind://删除提醒 mWorkingNote.setAlertDate(0, false); break; + case R.id.menu_set_habit: + // 设置为习惯便签并显示设置对话框 + mWorkingNote.setHabit(true, ""); + showHabitConfigDialog(); + break; + case R.id.menu_habit_settings: + // 显示习惯设置对话框 + showHabitConfigDialog(); + break; + case R.id.menu_stop_habit: + // 停止习惯,转换为普通便签 + AlertDialog.Builder stopHabitBuilder = new AlertDialog.Builder(this); + stopHabitBuilder.setTitle(R.string.habit_config_title); + stopHabitBuilder.setMessage("确定要停止这个习惯吗?"); + stopHabitBuilder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + // 将习惯便签转换为普通便签 + mWorkingNote.setHabit(false, ""); + // 取消习惯提醒 + cancelHabitAlarm(mWorkingNote); + // 隐藏习惯面板 + mHabitPanel.setVisibility(View.GONE); + // 刷新菜单 + invalidateOptionsMenu(); + // 刷新UI + renderHabitPanel(); + } + }); + stopHabitBuilder.setNegativeButton(android.R.string.cancel, null); + stopHabitBuilder.show(); + break; default: break; } diff --git a/src/ui/NoteItemData.java b/src/ui/NoteItemData.java index 7021ee6..c01ff2f 100644 --- a/src/ui/NoteItemData.java +++ b/src/ui/NoteItemData.java @@ -110,8 +110,13 @@ public class NoteItemData { mType = cursor.getInt(TYPE_COLUMN); mWidgetId = cursor.getInt(WIDGET_ID_COLUMN); mWidgetType = cursor.getInt(WIDGET_TYPE_COLUMN); + // habit flag, may not exist on older DBs + try { + mIsHabit = cursor.getInt(IS_HABIT_COLUMN) > 0; + } catch (Exception e) { + mIsHabit = false; + } - // 处理通话记录相关属性 mPhoneNumber = ""; if (mParentId == Notes.ID_CALL_RECORD_FOLDER) { // 获取通话记录的电话号码 @@ -326,6 +331,11 @@ public class NoteItemData { return (mAlertDate > 0); } + public boolean isHabit() { + return mIsHabit; + } + + /** * 是否为通话记录 * @return true如果是通话记录,否则false -- 2.34.1