新增智能日程功能、底部导航栏、界面架构优化 #29
Merged
pjao9fvxr
merged 2 commits from zhouzexin_branch into master 4 weeks ago
@ -0,0 +1,33 @@
|
||||
package net.micode.notes.model;
|
||||
|
||||
import com.google.firebase.firestore.IgnoreExtraProperties;
|
||||
|
||||
/**
|
||||
* 云端便签实体类 - 用于 Firebase Firestore 同步
|
||||
*/
|
||||
@IgnoreExtraProperties
|
||||
public class CloudNote {
|
||||
public String serverId; // 云端 ID
|
||||
public String title; // 标题
|
||||
public String content; // 完整正文内容
|
||||
public long modifiedDate; // 修改时间
|
||||
public int bgColorId; // 背景颜色 ID
|
||||
public int isAgenda; // 是否为日程 (0/1)
|
||||
public long agendaDate; // 日程时间戳
|
||||
public int isCompleted; // 是否完成 (0/1)
|
||||
|
||||
// Firebase 必须要求的空构造函数
|
||||
public CloudNote() {}
|
||||
|
||||
public CloudNote(String serverId, String title, String content, long modifiedDate,
|
||||
int bgColorId, int isAgenda, long agendaDate, int isCompleted) {
|
||||
this.serverId = serverId;
|
||||
this.title = title;
|
||||
this.content = content;
|
||||
this.modifiedDate = modifiedDate;
|
||||
this.bgColorId = bgColorId;
|
||||
this.isAgenda = isAgenda;
|
||||
this.agendaDate = agendaDate;
|
||||
this.isCompleted = isCompleted;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,127 @@
|
||||
package net.micode.notes.ui;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.database.Cursor;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.CalendarView;
|
||||
import android.widget.TextView;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import net.micode.notes.R;
|
||||
import net.micode.notes.data.Notes;
|
||||
import net.micode.notes.data.Notes.NoteColumns;
|
||||
import java.util.Calendar;
|
||||
|
||||
public class AgendaFragment extends Fragment {
|
||||
private RecyclerView mRecyclerView;
|
||||
private NotesListItemAdapter mAdapter;
|
||||
private CalendarView mCalendarView;
|
||||
private TextView mTvEmpty;
|
||||
private long mSelectedDayStart;
|
||||
private long mSelectedDayEnd;
|
||||
|
||||
private TextView tvDateHeader;
|
||||
private View emptyState;
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
||||
View view = inflater.inflate(R.layout.fragment_agenda, container, false);
|
||||
initUI(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
private void initUI(View view) {
|
||||
mCalendarView = view.findViewById(R.id.calendar_view);
|
||||
mRecyclerView = view.findViewById(R.id.agenda_list);
|
||||
tvDateHeader = view.findViewById(R.id.tv_agenda_date_header);
|
||||
emptyState = view.findViewById(R.id.ll_empty_state);
|
||||
|
||||
mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
|
||||
mAdapter = new NotesListItemAdapter(getContext());
|
||||
mRecyclerView.setAdapter(mAdapter);
|
||||
|
||||
// 默认显示今天
|
||||
updateDateDisplay(Calendar.getInstance().get(Calendar.YEAR),
|
||||
Calendar.getInstance().get(Calendar.MONTH),
|
||||
Calendar.getInstance().get(Calendar.DAY_OF_MONTH));
|
||||
|
||||
mCalendarView.setOnDateChangeListener((view1, year, month, dayOfMonth) -> {
|
||||
updateDateDisplay(year, month, dayOfMonth);
|
||||
loadAgendaData();
|
||||
});
|
||||
|
||||
loadAgendaData();
|
||||
}
|
||||
|
||||
private void updateDateDisplay(int year, int month, int day) {
|
||||
tvDateHeader.setText(String.format("%d年%d月%d日 的日程", year, month + 1, day));
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.set(year, month, day);
|
||||
calculateDayRange(cal.getTimeInMillis());
|
||||
}
|
||||
|
||||
// 计算选中日期的 [00:00:00, 23:59:59] 时间戳
|
||||
private void calculateDayRange(long timeInMillis) {
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.setTimeInMillis(timeInMillis);
|
||||
cal.set(Calendar.HOUR_OF_DAY, 0);
|
||||
cal.set(Calendar.MINUTE, 0);
|
||||
cal.set(Calendar.SECOND, 0);
|
||||
cal.set(Calendar.MILLISECOND, 0);
|
||||
mSelectedDayStart = cal.getTimeInMillis();
|
||||
|
||||
cal.set(Calendar.HOUR_OF_DAY, 23);
|
||||
cal.set(Calendar.MINUTE, 59);
|
||||
cal.set(Calendar.SECOND, 59);
|
||||
mSelectedDayEnd = cal.getTimeInMillis();
|
||||
}
|
||||
|
||||
private void loadAgendaData() {
|
||||
// 增加空检查,防止 fragment 已经 detach 时还执行异步回调
|
||||
if (!isAdded() || getContext() == null) return;
|
||||
|
||||
ContentResolver resolver = getContext().getContentResolver();
|
||||
|
||||
// 使用异步查询(推荐)或者简单的后台线程
|
||||
new Thread(() -> {
|
||||
String selection = NoteColumns.IS_AGENDA + "=1 AND "
|
||||
+ NoteColumns.AGENDA_DATE + " >= ? AND "
|
||||
+ NoteColumns.AGENDA_DATE + " <= ?";
|
||||
|
||||
String[] selectionArgs = new String[] {
|
||||
String.valueOf(mSelectedDayStart),
|
||||
String.valueOf(mSelectedDayEnd)
|
||||
};
|
||||
|
||||
// 执行查询
|
||||
final Cursor cursor = resolver.query(
|
||||
Notes.CONTENT_NOTE_URI,
|
||||
NoteItemData.PROJECTION,
|
||||
selection,
|
||||
selectionArgs,
|
||||
NoteColumns.AGENDA_DATE + " ASC"
|
||||
);
|
||||
|
||||
// 切回主线程更新 UI
|
||||
if (getActivity() != null) {
|
||||
getActivity().runOnUiThread(() -> {
|
||||
mAdapter.changeCursor(cursor);
|
||||
if (cursor == null || cursor.getCount() == 0) {
|
||||
emptyState.setVisibility(View.VISIBLE);
|
||||
mRecyclerView.setVisibility(View.GONE);
|
||||
} else {
|
||||
emptyState.setVisibility(View.GONE);
|
||||
mRecyclerView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,121 @@
|
||||
package net.micode.notes.ui;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.Toast;
|
||||
import com.google.firebase.auth.FirebaseUser;
|
||||
import android.content.ContentValues;
|
||||
import android.net.Uri;
|
||||
import net.micode.notes.data.Notes;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.google.firebase.auth.FirebaseAuth;
|
||||
|
||||
import net.micode.notes.R;
|
||||
|
||||
public class LoginActivity extends AppCompatActivity {
|
||||
private EditText mEmailField;
|
||||
private EditText mPasswordField;
|
||||
private Button mLoginBtn;
|
||||
private ProgressBar mLoadingBar;
|
||||
private FirebaseAuth mAuth;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_login);
|
||||
|
||||
// 1. 初始化 Firebase Auth
|
||||
mAuth = FirebaseAuth.getInstance();
|
||||
|
||||
// 2. 绑定 UI 控件
|
||||
mEmailField = findViewById(R.id.et_email);
|
||||
mPasswordField = findViewById(R.id.et_password);
|
||||
mLoginBtn = findViewById(R.id.btn_login);
|
||||
mLoadingBar = findViewById(R.id.loading_bar);
|
||||
|
||||
// 3. 登录逻辑
|
||||
mLoginBtn.setOnClickListener(v -> handleAuth());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
// [新增] 自动登录检查:如果 Firebase 已经记录了登录状态,直接跳过登录页
|
||||
FirebaseUser currentUser = mAuth.getCurrentUser();
|
||||
if (currentUser != null) {
|
||||
updateLocalAccount(currentUser); // 确保本地存储了最新 UID
|
||||
startActivity(new Intent(this, NotesListActivity.class));
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
// [新增] 辅助方法:将 Firebase 用户信息写入步骤 1.2 创建的数据库表中
|
||||
private void updateLocalAccount(FirebaseUser user) {
|
||||
android.content.ContentValues values = new android.content.ContentValues();
|
||||
values.put(net.micode.notes.data.Notes.AccountColumns.UID, user.getUid());
|
||||
values.put(net.micode.notes.data.Notes.AccountColumns.EMAIL, user.getEmail());
|
||||
|
||||
// 使用本地 ContentResolver 写入 user_account 表
|
||||
getContentResolver().insert(
|
||||
android.net.Uri.parse("content://" + net.micode.notes.data.Notes.AUTHORITY + "/user_account"),
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
private void handleAuth() {
|
||||
String email = mEmailField.getText().toString().trim();
|
||||
String password = mPasswordField.getText().toString().trim();
|
||||
|
||||
if (TextUtils.isEmpty(email) || TextUtils.isEmpty(password)) {
|
||||
Toast.makeText(this, "请填写邮箱和密码", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length() < 6) {
|
||||
Toast.makeText(this, "密码至少需要6位", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(true);
|
||||
|
||||
// 先尝试登录
|
||||
mAuth.signInWithEmailAndPassword(email, password)
|
||||
.addOnCompleteListener(this, task -> {
|
||||
if (task.isSuccessful()) {
|
||||
onAuthSuccess();
|
||||
} else {
|
||||
// 登录失败,尝试注册(方便演示)
|
||||
mAuth.createUserWithEmailAndPassword(email, password)
|
||||
.addOnCompleteListener(this, regTask -> {
|
||||
showLoading(false);
|
||||
if (regTask.isSuccessful()) {
|
||||
onAuthSuccess();
|
||||
} else {
|
||||
Toast.makeText(this, "认证失败: " + regTask.getException().getMessage(), Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void onAuthSuccess() {
|
||||
// [新增] 成功认证后,立即同步身份到本地
|
||||
FirebaseUser user = mAuth.getCurrentUser();
|
||||
updateLocalAccount(user);
|
||||
|
||||
Toast.makeText(this, "登录成功!", Toast.LENGTH_SHORT).show();
|
||||
startActivity(new Intent(this, NotesListActivity.class));
|
||||
finish();
|
||||
}
|
||||
|
||||
private void showLoading(boolean loading) {
|
||||
mLoadingBar.setVisibility(loading ? View.VISIBLE : View.GONE);
|
||||
mLoginBtn.setEnabled(!loading);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,281 @@
|
||||
package net.micode.notes.ui;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import net.micode.notes.data.Notes;
|
||||
import net.micode.notes.tool.DataUtils;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
|
||||
public class NotesListItemAdapter extends RecyclerView.Adapter<NotesListItemAdapter.ViewHolder> {
|
||||
private static final String TAG = "NotesListItemAdapter";
|
||||
private Context mContext;
|
||||
private Cursor mCursor;
|
||||
private HashMap<Integer, Boolean> mSelectedIndex;
|
||||
private int mNotesCount;
|
||||
private boolean mChoiceMode;
|
||||
|
||||
private OnItemClickListener mOnItemClickListener;
|
||||
private OnItemLongClickListener mOnItemLongClickListener;
|
||||
|
||||
public interface OnItemClickListener {
|
||||
void onItemClick(View view, int position);
|
||||
}
|
||||
|
||||
public interface OnItemLongClickListener {
|
||||
boolean onItemLongClick(View view, int position);
|
||||
}
|
||||
|
||||
public void setOnItemClickListener(OnItemClickListener listener) {
|
||||
mOnItemClickListener = listener;
|
||||
}
|
||||
|
||||
public void setOnItemLongClickListener(OnItemLongClickListener listener) {
|
||||
mOnItemLongClickListener = listener;
|
||||
}
|
||||
|
||||
/*
|
||||
public static class AppWidgetAttribute {
|
||||
public int widgetId;
|
||||
public int widgetType;
|
||||
}
|
||||
*/
|
||||
|
||||
public static class ViewHolder extends RecyclerView.ViewHolder {
|
||||
public ViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
}
|
||||
}
|
||||
|
||||
public NotesListItemAdapter(Context context) {
|
||||
mContext = context;
|
||||
mSelectedIndex = new HashMap<Integer, Boolean>();
|
||||
mNotesCount = 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
|
||||
NotesListItem view = new NotesListItem(mContext);
|
||||
view.setLayoutParams(new ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||
|
||||
final ViewHolder holder = new ViewHolder(view);
|
||||
|
||||
view.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
if (mOnItemClickListener != null) {
|
||||
mOnItemClickListener.onItemClick(v, holder.getAdapterPosition());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
view.setOnLongClickListener(new View.OnLongClickListener() {
|
||||
@Override
|
||||
public boolean onLongClick(View v) {
|
||||
if (mOnItemLongClickListener != null) {
|
||||
return mOnItemLongClickListener.onItemLongClick(v, holder.getAdapterPosition());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return holder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(ViewHolder holder, int position) {
|
||||
if (!mCursor.moveToPosition(position)) {
|
||||
Log.e(TAG, "couldn't move cursor to position " + position);
|
||||
return;
|
||||
}
|
||||
|
||||
View view = holder.itemView;
|
||||
if (view instanceof NotesListItem) {
|
||||
NoteItemData itemData = new NoteItemData(mContext, mCursor);
|
||||
((NotesListItem) view).bind(mContext, itemData, mChoiceMode,
|
||||
isSelectedItem(position));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
if (mCursor != null) {
|
||||
return mCursor.getCount();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void setCheckedItem(final int position, final boolean checked) {
|
||||
mSelectedIndex.put(position, checked);
|
||||
notifyItemChanged(position);
|
||||
}
|
||||
|
||||
public boolean isInChoiceMode() {
|
||||
return mChoiceMode;
|
||||
}
|
||||
|
||||
public void setChoiceMode(boolean mode) {
|
||||
mSelectedIndex.clear();
|
||||
mChoiceMode = mode;
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void selectAll(boolean checked) {
|
||||
Cursor cursor = getCursor();
|
||||
for (int i = 0; i < getItemCount(); i++) {
|
||||
if (cursor.moveToPosition(i)) {
|
||||
if (NoteItemData.getNoteType(cursor) == Notes.TYPE_NOTE) {
|
||||
setCheckedItem(i, checked);
|
||||
}
|
||||
}
|
||||
}
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public HashSet<Long> getSelectedItemIds() {
|
||||
HashSet<Long> itemSet = new HashSet<Long>();
|
||||
for (Integer position : mSelectedIndex.keySet()) {
|
||||
if (mSelectedIndex.get(position) == true) {
|
||||
Long id = getItemId(position);
|
||||
if (id == Notes.ID_ROOT_FOLDER) {
|
||||
Log.d(TAG, "Wrong item id, should not happen");
|
||||
} else {
|
||||
itemSet.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return itemSet;
|
||||
}
|
||||
|
||||
public HashSet<DataUtils.AppWidgetAttribute> getSelectedWidget() {
|
||||
HashSet<DataUtils.AppWidgetAttribute> itemSet = new HashSet<DataUtils.AppWidgetAttribute>();
|
||||
for (Integer position : mSelectedIndex.keySet()) {
|
||||
if (mSelectedIndex.get(position) == true) {
|
||||
Cursor c = (Cursor) getItem(position);
|
||||
if (c != null) {
|
||||
DataUtils.AppWidgetAttribute widget = new DataUtils.AppWidgetAttribute();
|
||||
NoteItemData item = new NoteItemData(mContext, c);
|
||||
widget.widgetId = item.getWidgetId();
|
||||
widget.widgetType = item.getWidgetType();
|
||||
itemSet.add(widget);
|
||||
} else {
|
||||
Log.e(TAG, "Invalid cursor");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return itemSet;
|
||||
}
|
||||
|
||||
public int getSelectedCount() {
|
||||
Collection<Boolean> values = mSelectedIndex.values();
|
||||
if (null == values) {
|
||||
return 0;
|
||||
}
|
||||
Iterator<Boolean> iter = values.iterator();
|
||||
int count = 0;
|
||||
while (iter.hasNext()) {
|
||||
if (true == iter.next()) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public boolean isAllSelected() {
|
||||
int checkedCount = getSelectedCount();
|
||||
return (checkedCount != 0 && checkedCount == mNotesCount);
|
||||
}
|
||||
|
||||
public boolean isSelectedItem(final int position) {
|
||||
if (null == mSelectedIndex.get(position)) {
|
||||
return false;
|
||||
}
|
||||
return mSelectedIndex.get(position);
|
||||
}
|
||||
|
||||
public void changeCursor(Cursor cursor) {
|
||||
if (mCursor == cursor) {
|
||||
return;
|
||||
}
|
||||
if (mCursor != null) {
|
||||
mCursor.close();
|
||||
}
|
||||
mCursor = cursor;
|
||||
if (mCursor != null) {
|
||||
calcNotesCount();
|
||||
}
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public Cursor getCursor() {
|
||||
return mCursor;
|
||||
}
|
||||
|
||||
public Object getItem(int position) {
|
||||
if (mCursor != null && mCursor.moveToPosition(position)) {
|
||||
return mCursor;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public long getItemId(int position) {
|
||||
if (mCursor != null && mCursor.moveToPosition(position)) {
|
||||
// 使用标准的 NoteColumns.ID 常量,确保安全
|
||||
int idColumnIndex = mCursor.getColumnIndex(net.micode.notes.data.Notes.NoteColumns.ID);
|
||||
return mCursor.getLong(idColumnIndex);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private void calcNotesCount() {
|
||||
mNotesCount = 0;
|
||||
for (int i = 0; i < getItemCount(); i++) {
|
||||
Cursor c = (Cursor) getItem(i);
|
||||
if (c != null) {
|
||||
if (NoteItemData.getNoteType(c) == Notes.TYPE_NOTE) {
|
||||
mNotesCount++;
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Invalid cursor");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [新增] 判断当前选中的所有项是否都已经置顶
|
||||
public boolean isAllSelectedItemsPinned() {
|
||||
if (mSelectedIndex == null || mSelectedIndex.size() == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// [Fix] 动态获取索引,比写死 13 更安全
|
||||
int isPinnedColumnIndex = mCursor.getColumnIndex(Notes.NoteColumns.IS_PINNED);
|
||||
if (isPinnedColumnIndex == -1) {
|
||||
return false; // 如果查不到该列,默认视为未置顶
|
||||
}
|
||||
|
||||
for (Integer position : mSelectedIndex.keySet()) {
|
||||
if (mSelectedIndex.get(position)) {
|
||||
if (mCursor != null && mCursor.moveToPosition(position)) {
|
||||
// 使用动态索引读取
|
||||
int isPinned = mCursor.getInt(isPinnedColumnIndex);
|
||||
if (isPinned <= 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue