refactor: 优化日历状态管理与首页输入框,添加API客户端抽象
This commit is contained in:
@@ -37,3 +37,11 @@ Follow this hierarchy when developing:
|
||||
- Default branch: `dev`
|
||||
- Feature development: use worktree `git worktree add -b feature/xxx ../feature-xxx dev`
|
||||
- Never develop directly on `main`
|
||||
|
||||
## Supabase Services
|
||||
|
||||
Project uses locally hosted Supabase for development.
|
||||
|
||||
- Docker config: `infra/docker/docker-compose.yml`
|
||||
- Start services: `cd infra/docker && docker compose up -d`
|
||||
- Stop services: `cd infra/docker && docker compose down`
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'api_client.dart';
|
||||
import 'mock_api_client.dart';
|
||||
|
||||
abstract class IApiClient {
|
||||
Future<Response<T>> get<T>(String path, {Options? options});
|
||||
Future<Response<T>> post<T>(String path, {dynamic data, Options? options});
|
||||
Future<Response<T>> patch<T>(String path, {dynamic data, Options? options});
|
||||
Future<Response<T>> delete<T>(String path, {dynamic data, Options? options});
|
||||
}
|
||||
|
||||
class ApiClientWrapper implements IApiClient {
|
||||
final ApiClient _client;
|
||||
|
||||
ApiClientWrapper(this._client);
|
||||
|
||||
@override
|
||||
Future<Response<T>> get<T>(String path, {Options? options}) =>
|
||||
_client.get(path, options: options);
|
||||
|
||||
@override
|
||||
Future<Response<T>> post<T>(String path, {dynamic data, Options? options}) =>
|
||||
_client.post(path, data: data, options: options);
|
||||
|
||||
@override
|
||||
Future<Response<T>> patch<T>(String path, {dynamic data, Options? options}) =>
|
||||
_client.patch(path, data: data, options: options);
|
||||
|
||||
@override
|
||||
Future<Response<T>> delete<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Options? options,
|
||||
}) => _client.delete(path, data: data, options: options);
|
||||
}
|
||||
|
||||
class MockApiClientWrapper implements IApiClient {
|
||||
final MockApiClient _client;
|
||||
|
||||
MockApiClientWrapper(this._client);
|
||||
|
||||
@override
|
||||
Future<Response<T>> get<T>(String path, {Options? options}) =>
|
||||
_client.get(path, options: options);
|
||||
|
||||
@override
|
||||
Future<Response<T>> post<T>(String path, {dynamic data, Options? options}) =>
|
||||
_client.post(path, data: data, options: options);
|
||||
|
||||
@override
|
||||
Future<Response<T>> patch<T>(String path, {dynamic data, Options? options}) =>
|
||||
_client.patch(path, data: data, options: options);
|
||||
|
||||
@override
|
||||
Future<Response<T>> delete<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Options? options,
|
||||
}) => _client.delete(path, data: data, options: options);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
class MockApiClient {
|
||||
final Map<String, _MockHandler> _handlers = {};
|
||||
|
||||
void registerHandler(String path, String method, _MockHandler handler) {
|
||||
final key = '$path:$method';
|
||||
_handlers[key] = handler;
|
||||
}
|
||||
|
||||
void clearMocks() {
|
||||
_handlers.clear();
|
||||
}
|
||||
|
||||
Future<Response<T>> get<T>(String path, {Options? options}) async {
|
||||
return _handleRequest('GET', path, options: options);
|
||||
}
|
||||
|
||||
Future<Response<T>> post<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Options? options,
|
||||
}) async {
|
||||
return _handleRequest('POST', path, data: data, options: options);
|
||||
}
|
||||
|
||||
Future<Response<T>> patch<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Options? options,
|
||||
}) async {
|
||||
return _handleRequest('PATCH', path, data: data, options: options);
|
||||
}
|
||||
|
||||
Future<Response<T>> delete<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Options? options,
|
||||
}) async {
|
||||
return _handleRequest('DELETE', path, data: data, options: options);
|
||||
}
|
||||
|
||||
Future<Response<T>> _handleRequest<T>(
|
||||
String method,
|
||||
String path, {
|
||||
dynamic data,
|
||||
Options? options,
|
||||
}) async {
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
final key = '$path:$method';
|
||||
final handler = _handlers[key];
|
||||
|
||||
if (handler != null) {
|
||||
final response = handler(data);
|
||||
if (response is Response) {
|
||||
return response as Response<T>;
|
||||
}
|
||||
return Response<T>(
|
||||
data: response as T?,
|
||||
statusCode: 200,
|
||||
requestOptions: RequestOptions(path: path),
|
||||
);
|
||||
}
|
||||
|
||||
return Response<T>(
|
||||
data: null,
|
||||
statusCode: 404,
|
||||
requestOptions: RequestOptions(path: path),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef _MockHandler = dynamic Function(dynamic data);
|
||||
|
||||
class MockApiClientHolder {
|
||||
static MockApiClient? _instance;
|
||||
|
||||
static MockApiClient get instance {
|
||||
_instance ??= MockApiClient();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
static void reset() {
|
||||
_instance = null;
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,12 @@ class Env {
|
||||
}
|
||||
return 'http://localhost:5775';
|
||||
}
|
||||
|
||||
static bool get isMockApi {
|
||||
final fromDefine = const String.fromEnvironment('MOCK_API');
|
||||
if (fromDefine.isNotEmpty) {
|
||||
return fromDefine == 'true';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../features/auth/data/auth_api.dart';
|
||||
import '../../features/auth/data/auth_repository.dart';
|
||||
import '../../features/auth/data/auth_repository_impl.dart';
|
||||
import '../../features/auth/presentation/bloc/auth_bloc.dart';
|
||||
import '../../features/calendar/ui/calendar_state_manager.dart';
|
||||
|
||||
final sl = GetIt.instance;
|
||||
|
||||
@@ -46,4 +47,6 @@ Future<void> configureDependencies() async {
|
||||
});
|
||||
|
||||
sl.registerSingleton<AuthBloc>(AuthBloc(authRepository));
|
||||
|
||||
sl.registerSingleton<CalendarStateManager>(CalendarStateManager());
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../../features/contacts/ui/screens/add_contact_screen.dart';
|
||||
import '../../features/calendar/ui/screens/calendar_dayweek_screen.dart';
|
||||
import '../../features/calendar/ui/screens/calendar_month_screen.dart';
|
||||
import '../../features/calendar/ui/screens/calendar_event_detail_screen.dart';
|
||||
import '../../features/calendar/ui/calendar_time_utils.dart';
|
||||
import '../../features/todo/ui/screens/todo_quadrants_screen.dart';
|
||||
import '../../features/todo/ui/screens/todo_detail_screen.dart';
|
||||
import '../../features/settings/ui/screens/settings_screen.dart';
|
||||
@@ -44,6 +45,7 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
final authState = authBloc.state;
|
||||
final isAuthenticated = authState is AuthAuthenticated;
|
||||
final isAuthRoute =
|
||||
state.matchedLocation == '/' ||
|
||||
state.matchedLocation.startsWith('/login') ||
|
||||
state.matchedLocation.startsWith('/register');
|
||||
final isProtected = _protectedRoutes.any(
|
||||
@@ -91,11 +93,21 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
),
|
||||
GoRoute(
|
||||
path: '/calendar/dayweek',
|
||||
builder: (context, state) => const CalendarDayWeekScreen(),
|
||||
builder: (context, state) {
|
||||
final fromHome = state.uri.queryParameters['from'] == 'home';
|
||||
final initialDate = parseYmd(state.uri.queryParameters['date']);
|
||||
return CalendarDayWeekScreen(
|
||||
initialDate: initialDate,
|
||||
resetToToday: fromHome,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/calendar/month',
|
||||
builder: (context, state) => const CalendarMonthScreen(),
|
||||
builder: (context, state) {
|
||||
final fromHome = state.uri.queryParameters['from'] == 'home';
|
||||
return CalendarMonthScreen(resetToToday: fromHome);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/calendar/events/:id',
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum CalendarViewType { day, month }
|
||||
|
||||
class CalendarState {
|
||||
final CalendarViewType viewType;
|
||||
final DateTime selectedDate;
|
||||
|
||||
CalendarState({required this.viewType, required this.selectedDate});
|
||||
|
||||
CalendarState copyWith({CalendarViewType? viewType, DateTime? selectedDate}) {
|
||||
return CalendarState(
|
||||
viewType: viewType ?? this.viewType,
|
||||
selectedDate: selectedDate ?? this.selectedDate,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CalendarStateManager extends ChangeNotifier {
|
||||
CalendarState _state;
|
||||
|
||||
CalendarStateManager()
|
||||
: _state = CalendarState(
|
||||
viewType: CalendarViewType.month,
|
||||
selectedDate: DateTime.now(),
|
||||
);
|
||||
|
||||
CalendarState get state => _state;
|
||||
|
||||
CalendarViewType get viewType => _state.viewType;
|
||||
DateTime get selectedDate => _state.selectedDate;
|
||||
|
||||
void setViewType(CalendarViewType type) {
|
||||
_state = _state.copyWith(viewType: type);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setSelectedDate(DateTime date) {
|
||||
_state = _state.copyWith(selectedDate: date);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void resetToToday() {
|
||||
final now = DateTime.now();
|
||||
_state = CalendarState(
|
||||
viewType: CalendarViewType.month,
|
||||
selectedDate: DateTime(now.year, now.month, now.day),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
DateTime weekStartFor(DateTime date) {
|
||||
return date.subtract(Duration(days: date.weekday % 7));
|
||||
}
|
||||
|
||||
bool isSameDay(DateTime a, DateTime b) {
|
||||
return a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
}
|
||||
|
||||
bool shouldShowCurrentMarker(DateTime selectedDate, DateTime now) {
|
||||
return isSameDay(selectedDate, now);
|
||||
}
|
||||
|
||||
String formatHm(DateTime dateTime) {
|
||||
final hour = dateTime.hour.toString().padLeft(2, '0');
|
||||
final minute = dateTime.minute.toString().padLeft(2, '0');
|
||||
return '$hour:$minute';
|
||||
}
|
||||
|
||||
String formatHour(int hour) {
|
||||
if (hour == 24) {
|
||||
return '00:00';
|
||||
}
|
||||
return '${hour.toString().padLeft(2, '0')}:00';
|
||||
}
|
||||
|
||||
DateTime? parseYmd(String? ymd) {
|
||||
if (ymd == null) {
|
||||
return null;
|
||||
}
|
||||
final matched = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(ymd);
|
||||
if (matched == null) {
|
||||
return null;
|
||||
}
|
||||
final year = int.parse(matched.group(1)!);
|
||||
final month = int.parse(matched.group(2)!);
|
||||
final day = int.parse(matched.group(3)!);
|
||||
final parsed = DateTime(year, month, day);
|
||||
if (parsed.year != year || parsed.month != month || parsed.day != day) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
String formatYmd(DateTime dateTime) {
|
||||
final year = dateTime.year.toString().padLeft(4, '0');
|
||||
final month = dateTime.month.toString().padLeft(2, '0');
|
||||
final day = dateTime.day.toString().padLeft(2, '0');
|
||||
return '$year-$month-$day';
|
||||
}
|
||||
|
||||
List<DateTime> monthDatesFor(DateTime date) {
|
||||
final monthStart = DateTime(date.year, date.month, 1);
|
||||
final monthEnd = DateTime(date.year, date.month + 1, 0);
|
||||
return List.generate(
|
||||
monthEnd.day,
|
||||
(index) => DateTime(monthStart.year, monthStart.month, index + 1),
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../calendar_state_manager.dart';
|
||||
import '../calendar_time_utils.dart';
|
||||
import '../widgets/bottom_dock.dart';
|
||||
|
||||
class CalendarDayWeekScreen extends StatefulWidget {
|
||||
const CalendarDayWeekScreen({super.key});
|
||||
final DateTime? initialDate;
|
||||
final bool resetToToday;
|
||||
|
||||
const CalendarDayWeekScreen({
|
||||
super.key,
|
||||
this.initialDate,
|
||||
this.resetToToday = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CalendarDayWeekScreen> createState() => _CalendarDayWeekScreenState();
|
||||
}
|
||||
|
||||
class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen> {
|
||||
DateTime _selectedDate = DateTime(2026, 2, 9);
|
||||
late DateTime _weekStart;
|
||||
static const double _dayItemWidth = 44;
|
||||
static const double _dayItemGap = 12;
|
||||
|
||||
late final CalendarStateManager _calendarManager;
|
||||
late DateTime _selectedDate;
|
||||
late List<DateTime> _monthDates;
|
||||
final ScrollController _dayStripController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_weekStart = _getWeekStart(_selectedDate);
|
||||
_calendarManager = sl<CalendarStateManager>();
|
||||
|
||||
if (widget.resetToToday) {
|
||||
_calendarManager.resetToToday();
|
||||
}
|
||||
|
||||
_selectedDate = _calendarManager.selectedDate;
|
||||
_updateMonthDates();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToSelectedDate();
|
||||
});
|
||||
}
|
||||
|
||||
DateTime _getWeekStart(DateTime date) {
|
||||
return date.subtract(Duration(days: date.weekday % 7));
|
||||
void _updateMonthDates() {
|
||||
_monthDates = monthDatesFor(_selectedDate);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_dayStripController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8FAFC),
|
||||
backgroundColor: AppColors.todoBg,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -37,8 +69,8 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen> {
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
left: AppSpacing.lg,
|
||||
right: AppSpacing.lg,
|
||||
top: 2,
|
||||
bottom: 104,
|
||||
),
|
||||
@@ -67,14 +99,14 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen> {
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
onTap: () => context.go('/calendar/month'),
|
||||
child: Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFF),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: const Color(0xFFDEE7F6)),
|
||||
color: AppColors.messageBtnWrap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
border: Border.all(color: AppColors.messageBtnBorder),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -106,29 +138,70 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen> {
|
||||
Widget _buildWeekStrip() {
|
||||
return SizedBox(
|
||||
height: 86,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: List.generate(7, (index) {
|
||||
final date = _weekStart.add(Duration(days: index));
|
||||
final isSelected =
|
||||
date.day == _selectedDate.day &&
|
||||
date.month == _selectedDate.month &&
|
||||
date.year == _selectedDate.year;
|
||||
final isWeekend = index == 0 || index == 6;
|
||||
child: ListView.separated(
|
||||
controller: _dayStripController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _monthDates.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(width: _dayItemGap),
|
||||
itemBuilder: (context, index) {
|
||||
final date = _monthDates[index];
|
||||
final isSelected = isSameDay(date, _selectedDate);
|
||||
final isWeekend = date.weekday % 7 == 0 || date.weekday % 7 == 6;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedDate = date;
|
||||
});
|
||||
_calendarManager.setSelectedDate(date);
|
||||
_updateMonthDates();
|
||||
_scrollToSelectedDate(animate: true);
|
||||
},
|
||||
child: _buildDayItem(date, isSelected, isWeekend),
|
||||
child: SizedBox(
|
||||
width: _dayItemWidth,
|
||||
child: _buildDayItem(date, isSelected, isWeekend),
|
||||
),
|
||||
);
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _scrollToSelectedDate({bool animate = false}) {
|
||||
if (!_dayStripController.hasClients) {
|
||||
return;
|
||||
}
|
||||
final index = _monthDates.indexWhere(
|
||||
(date) => isSameDay(date, _selectedDate),
|
||||
);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final targetCenter =
|
||||
index * (_dayItemWidth + _dayItemGap) + (_dayItemWidth / 2);
|
||||
final viewport = _dayStripController.position.viewportDimension;
|
||||
var offset = targetCenter - (viewport / 2);
|
||||
final max = _dayStripController.position.maxScrollExtent;
|
||||
if (offset < 0) {
|
||||
offset = 0;
|
||||
}
|
||||
if (offset > max) {
|
||||
offset = max;
|
||||
}
|
||||
|
||||
if (animate) {
|
||||
_dayStripController.animateTo(
|
||||
offset,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
return;
|
||||
}
|
||||
_dayStripController.jumpTo(offset);
|
||||
}
|
||||
|
||||
Widget _buildDayItem(DateTime date, bool isSelected, bool isWeekend) {
|
||||
final dayNames = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
@@ -146,7 +219,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen> {
|
||||
Text(
|
||||
'${date.day}',
|
||||
style: TextStyle(
|
||||
fontSize: isSelected ? 17 : (isWeekend ? 17 : 17),
|
||||
fontSize: 17,
|
||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600,
|
||||
color: isSelected
|
||||
? AppColors.blue600
|
||||
@@ -158,49 +231,23 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen> {
|
||||
}
|
||||
|
||||
Widget _buildTimelineBoard() {
|
||||
return Column(
|
||||
children: [
|
||||
_buildTimelineRow('07:00', false),
|
||||
_buildTimelineRow('08:00', false),
|
||||
_buildTimelineRow(
|
||||
'09:00',
|
||||
true,
|
||||
eventText: '购票提醒',
|
||||
eventColor: AppColors.slate500,
|
||||
),
|
||||
_buildTimelineRow('10:00', false),
|
||||
_buildTimelineRow('11:00', false),
|
||||
_buildTimelineRow('12:00', false),
|
||||
_buildTimelineRow('13:00', false),
|
||||
_buildTimelineRow('14:00', false),
|
||||
_buildTimelineRow('15:00', false),
|
||||
_buildTimelineRow('15:28', false, isCurrentTime: true),
|
||||
_buildTimelineRow(
|
||||
'16:00',
|
||||
true,
|
||||
eventText: '购票提醒',
|
||||
eventColor: const Color(0xFF6B21A8),
|
||||
eventBg: const Color(0xFFE9D5FF),
|
||||
eventBorder: const Color(0xFFD8B4FE),
|
||||
),
|
||||
_buildTimelineRow('17:00', false),
|
||||
_buildTimelineRow('18:00', false),
|
||||
_buildTimelineRow('19:00', false),
|
||||
_buildTimelineRow('20:00', false),
|
||||
_buildTimelineRow('21:00', false),
|
||||
_buildTimelineRow('22:00', false),
|
||||
_buildTimelineRow('00:00', false, isDisabled: true),
|
||||
],
|
||||
);
|
||||
final now = DateTime.now();
|
||||
final showCurrent = shouldShowCurrentMarker(_selectedDate, now);
|
||||
final rows = <Widget>[];
|
||||
|
||||
for (var hour = 7; hour <= 22; hour++) {
|
||||
rows.add(_buildTimelineRow(formatHour(hour)));
|
||||
if (showCurrent && now.hour == hour) {
|
||||
rows.add(_buildTimelineRow(formatHm(now), isCurrentTime: true));
|
||||
}
|
||||
}
|
||||
|
||||
rows.add(_buildTimelineRow(formatHour(24), isDisabled: true));
|
||||
return Column(children: rows);
|
||||
}
|
||||
|
||||
Widget _buildTimelineRow(
|
||||
String time,
|
||||
bool hasEvent, {
|
||||
String? eventText,
|
||||
Color? eventColor,
|
||||
Color? eventBg,
|
||||
Color? eventBorder,
|
||||
String time, {
|
||||
bool isCurrentTime = false,
|
||||
bool isDisabled = false,
|
||||
}) {
|
||||
@@ -215,7 +262,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen> {
|
||||
width: 44,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEF4444),
|
||||
color: AppColors.red500,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: Center(
|
||||
@@ -237,7 +284,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen> {
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDisabled
|
||||
? AppColors.slate300
|
||||
: const Color(0xFF9CA3AF),
|
||||
: AppColors.slate400,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -247,38 +294,13 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen> {
|
||||
? Container(
|
||||
height: 2,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEF4444),
|
||||
color: AppColors.red500,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
)
|
||||
: hasEvent
|
||||
? Container(
|
||||
height: 22,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: eventBg ?? const Color(0xFFE5E7EB),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: eventBorder ?? const Color(0xFFD1D5DB),
|
||||
),
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
eventText ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: eventColor ?? const Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
height: 1,
|
||||
color: isDisabled
|
||||
? const Color(0xFFECEFF4)
|
||||
: const Color(0xFFE5E7EB),
|
||||
color: isDisabled ? AppColors.blue50 : AppColors.border,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -289,9 +311,15 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen> {
|
||||
Widget _buildBottomDock() {
|
||||
return BottomDock(
|
||||
activeTab: DockTab.calendar,
|
||||
onTodoTap: () => context.push('/todo'),
|
||||
onCalendarTap: () {},
|
||||
onHomeTap: () => Navigator.of(context).pop(),
|
||||
onTodoTap: () {
|
||||
_calendarManager.setViewType(CalendarViewType.day);
|
||||
context.push('/todo');
|
||||
},
|
||||
onCalendarTap: () {
|
||||
_calendarManager.setViewType(CalendarViewType.day);
|
||||
context.go('/calendar/month');
|
||||
},
|
||||
onHomeTap: () => context.go('/home'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,24 +2,44 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../calendar_state_manager.dart';
|
||||
import '../calendar_time_utils.dart';
|
||||
import '../widgets/bottom_dock.dart';
|
||||
|
||||
class CalendarMonthScreen extends StatefulWidget {
|
||||
const CalendarMonthScreen({super.key});
|
||||
final bool resetToToday;
|
||||
|
||||
const CalendarMonthScreen({super.key, this.resetToToday = false});
|
||||
|
||||
@override
|
||||
State<CalendarMonthScreen> createState() => _CalendarMonthScreenState();
|
||||
}
|
||||
|
||||
class _CalendarMonthScreenState extends State<CalendarMonthScreen> {
|
||||
DateTime _currentMonth = DateTime(2026, 2, 1);
|
||||
DateTime? _selectedDate;
|
||||
late final CalendarStateManager _calendarManager;
|
||||
late DateTime _currentMonth;
|
||||
late DateTime _selectedDate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_calendarManager = sl<CalendarStateManager>();
|
||||
|
||||
if (widget.resetToToday) {
|
||||
_calendarManager.resetToToday();
|
||||
}
|
||||
|
||||
final savedDate = _calendarManager.selectedDate;
|
||||
_selectedDate = savedDate;
|
||||
_currentMonth = DateTime(savedDate.year, savedDate.month, 1);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8FAFC),
|
||||
backgroundColor: AppColors.todoBg,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -84,7 +104,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen> {
|
||||
return Column(
|
||||
children: [
|
||||
_buildWeekdayHeader(),
|
||||
Container(height: 1, color: const Color(0xFFE5E7EB)),
|
||||
Container(height: 1, color: AppColors.border),
|
||||
..._buildWeeks(),
|
||||
],
|
||||
);
|
||||
@@ -108,7 +128,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen> {
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF9CA3AF),
|
||||
color: AppColors.slate400,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -140,7 +160,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen> {
|
||||
for (var weekStart = 0; weekStart < totalCells; weekStart += 7) {
|
||||
weeks.add(_buildWeekRow(weekStart, startWeekday, daysInMonth));
|
||||
if (weekStart + 7 < totalCells) {
|
||||
weeks.add(Container(height: 1, color: const Color(0xFFE5E7EB)));
|
||||
weeks.add(Container(height: 1, color: AppColors.border));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,24 +185,23 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen> {
|
||||
_currentMonth.month,
|
||||
dayIndex,
|
||||
);
|
||||
final isSelected =
|
||||
_selectedDate != null &&
|
||||
_selectedDate!.day == dayIndex &&
|
||||
_selectedDate!.month == _currentMonth.month;
|
||||
final isSelected = isSameDay(_selectedDate, date);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedDate = date;
|
||||
});
|
||||
_calendarManager.setSelectedDate(date);
|
||||
_calendarManager.setViewType(CalendarViewType.month);
|
||||
final ymd = formatYmd(date);
|
||||
context.push('/calendar/dayweek?date=$ymd');
|
||||
},
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? const Color(0xFFDBEAFE)
|
||||
: Colors.transparent,
|
||||
color: isSelected ? AppColors.blue100 : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Center(
|
||||
@@ -217,64 +236,15 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: List.generate(7, (index) {
|
||||
final dayIndex = weekStart + index - startWeekday + 1;
|
||||
if (dayIndex == 10) {
|
||||
return _buildEventDot();
|
||||
if (dayIndex < 1 || dayIndex > daysInMonth) {
|
||||
return const SizedBox(width: 38, height: 1);
|
||||
}
|
||||
return const SizedBox(width: 38, height: 1);
|
||||
return const SizedBox(width: 38, height: 20);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEventDot() {
|
||||
return SizedBox(
|
||||
width: 76,
|
||||
height: 100,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 20,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE5E7EB),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'购票提醒',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
height: 20,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE9D5FF),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'购票提醒',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF6B21A8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showMonthPicker() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -351,9 +321,12 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen> {
|
||||
Widget _buildBottomDock() {
|
||||
return BottomDock(
|
||||
activeTab: DockTab.calendar,
|
||||
onTodoTap: () => context.push('/todo'),
|
||||
onTodoTap: () {
|
||||
_calendarManager.setViewType(CalendarViewType.month);
|
||||
context.push('/todo');
|
||||
},
|
||||
onCalendarTap: () {},
|
||||
onHomeTap: () => Navigator.of(context).pop(),
|
||||
onHomeTap: () => context.go('/home'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
|
||||
enum DockTab { todo, calendar }
|
||||
|
||||
@@ -20,8 +21,13 @@ class BottomDock extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 61,
|
||||
padding: const EdgeInsets.only(left: 20, right: 20, top: 12, bottom: 18),
|
||||
height: 72,
|
||||
padding: const EdgeInsets.only(
|
||||
left: AppSpacing.xl,
|
||||
right: AppSpacing.xl,
|
||||
top: AppSpacing.md,
|
||||
bottom: AppSpacing.lg,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [_buildToggle(), _buildHomeBtn()],
|
||||
@@ -33,9 +39,9 @@ class BottomDock extends StatelessWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFDFEFF),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: const Color(0xFFDCE6F4)),
|
||||
color: AppColors.todoToggleBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xxl),
|
||||
border: Border.all(color: AppColors.todoToggleBorder),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -67,16 +73,18 @@ class BottomDock extends StatelessWidget {
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? const Color(0xFFD6E6FF) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
color: isActive ? AppColors.todoToggleActiveBg : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
border: Border.all(
|
||||
color: isActive ? const Color(0xFFBFD6FB) : Colors.transparent,
|
||||
color: isActive
|
||||
? AppColors.todoToggleActiveBorder
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: isActive ? const Color(0xFF1D4ED8) : const Color(0xFF334155),
|
||||
color: isActive ? AppColors.blue600 : AppColors.slate700,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -89,11 +97,15 @@ class BottomDock extends StatelessWidget {
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE6EEFB),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: const Color(0xFFC9D8EE)),
|
||||
color: AppColors.todoHomeBtnBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
border: Border.all(color: AppColors.todoHomeBtnBorder),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.home,
|
||||
size: 20,
|
||||
color: AppColors.slate700,
|
||||
),
|
||||
child: const Icon(LucideIcons.home, size: 20, color: Color(0xFF1E3A8A)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,35 @@ import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import 'home_sheet.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
|
||||
bool get _hasMessage => _messageController.text.trim().isNotEmpty;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_messageController.addListener(_onMessageChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageController.removeListener(_onMessageChanged);
|
||||
_messageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onMessageChanged() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -47,7 +73,7 @@ class HomeScreen extends StatelessWidget {
|
||||
size: 24,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
onPressed: () => context.push('/calendar/dayweek'),
|
||||
onPressed: () => context.push('/calendar/dayweek?from=home'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
@@ -153,10 +179,10 @@ class HomeScreen extends StatelessWidget {
|
||||
|
||||
Widget _buildInputContainer(BuildContext context) {
|
||||
return Container(
|
||||
height: 80,
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: const Color(0xFFF8FAFC),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => _showBottomSheet(context),
|
||||
@@ -166,7 +192,7 @@ class HomeScreen extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
border: Border.all(color: AppColors.slate300),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.plus,
|
||||
@@ -178,28 +204,40 @@ class HomeScreen extends StatelessWidget {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
constraints: const BoxConstraints(minHeight: 48),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: AppColors.slate300),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Expanded(
|
||||
Expanded(
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
controller: _messageController,
|
||||
minLines: 1,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入消息...',
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
focusedErrorBorder: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
filled: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
LucideIcons.mic,
|
||||
size: 20,
|
||||
color: AppColors.slate500,
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
_hasMessage ? LucideIcons.send : LucideIcons.mic,
|
||||
size: 24,
|
||||
color: _hasMessage ? AppColors.blue600 : AppColors.slate500,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../calendar/ui/calendar_state_manager.dart';
|
||||
|
||||
class TodoQuadrantsScreen extends StatelessWidget {
|
||||
const TodoQuadrantsScreen({super.key});
|
||||
@@ -216,7 +218,18 @@ class TodoQuadrantsScreen extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(
|
||||
onTap: () => context.push('/calendar/dayweek'),
|
||||
onTap: () {
|
||||
final manager = sl<CalendarStateManager>();
|
||||
final viewType = manager.viewType;
|
||||
final date = manager.selectedDate;
|
||||
final dateStr =
|
||||
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
if (viewType == CalendarViewType.month) {
|
||||
context.push('/calendar/month');
|
||||
} else {
|
||||
context.push('/calendar/dayweek?date=$dateStr');
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
|
||||
+12
-1
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'core/config/env.dart';
|
||||
import 'core/di/injection.dart';
|
||||
import 'core/router/app_router.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'features/auth/data/models/auth_response.dart';
|
||||
import 'features/auth/presentation/bloc/auth_bloc.dart';
|
||||
import 'features/auth/presentation/bloc/auth_event.dart';
|
||||
|
||||
@@ -11,7 +13,16 @@ void main() async {
|
||||
await configureDependencies();
|
||||
|
||||
final authBloc = sl<AuthBloc>();
|
||||
authBloc.add(AuthStarted());
|
||||
|
||||
if (Env.isMockApi) {
|
||||
authBloc.add(
|
||||
AuthLoggedIn(
|
||||
user: AuthUser(id: 'user_001', email: 'test@example.com'),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
authBloc.add(AuthStarted());
|
||||
}
|
||||
|
||||
runApp(LinksyApp(authBloc: authBloc));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/calendar/ui/calendar_time_utils.dart';
|
||||
|
||||
void main() {
|
||||
group('calendar_time_utils', () {
|
||||
test('returns week start on sunday', () {
|
||||
final date = DateTime(2026, 2, 11);
|
||||
final weekStart = weekStartFor(date);
|
||||
|
||||
expect(weekStart.year, 2026);
|
||||
expect(weekStart.month, 2);
|
||||
expect(weekStart.day, 8);
|
||||
});
|
||||
|
||||
test('shows current marker only for selected today', () {
|
||||
final now = DateTime(2026, 2, 11, 15, 28);
|
||||
|
||||
expect(shouldShowCurrentMarker(DateTime(2026, 2, 11), now), isTrue);
|
||||
expect(shouldShowCurrentMarker(DateTime(2026, 2, 10), now), isFalse);
|
||||
});
|
||||
|
||||
test('formats hour minute with zero pad', () {
|
||||
expect(formatHm(DateTime(2026, 2, 11, 7, 5)), '07:05');
|
||||
expect(formatHm(DateTime(2026, 2, 11, 15, 28)), '15:28');
|
||||
});
|
||||
|
||||
test('parses and formats ymd date string', () {
|
||||
final parsed = parseYmd('2026-02-11');
|
||||
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.year, 2026);
|
||||
expect(parsed.month, 2);
|
||||
expect(parsed.day, 11);
|
||||
expect(formatYmd(parsed), '2026-02-11');
|
||||
});
|
||||
|
||||
test('returns null for invalid ymd date string', () {
|
||||
expect(parseYmd('2026/02/11'), isNull);
|
||||
expect(parseYmd('bad-input'), isNull);
|
||||
expect(parseYmd(null), isNull);
|
||||
});
|
||||
|
||||
test('builds all dates for month', () {
|
||||
final dates = monthDatesFor(DateTime(2026, 2, 9));
|
||||
|
||||
expect(dates.length, 28);
|
||||
expect(formatYmd(dates.first), '2026-02-01');
|
||||
expect(formatYmd(dates.last), '2026-02-28');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
# Auth UX Enhancement Design
|
||||
|
||||
**日期**: 2026-02-26
|
||||
**状态**: 可实施(修订版)
|
||||
|
||||
## 目标
|
||||
|
||||
本次改动聚焦 4 个问题:
|
||||
1. 注册验证码页增加首次提示,降低用户困惑。
|
||||
2. 增加忘记密码流程(验证码模式)。
|
||||
3. 注册页增加邀请码输入(前端收集,后端暂不消费)。
|
||||
4. 修复用户名非唯一导致的用户查询问题,改为搜索接口。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不实现邀请码校验/入库。
|
||||
- 不改动 Supabase 邮件模板基础设施(当前 self-hosted 已配置 recovery 模板 URL)。
|
||||
- 不在本次引入新的认证机制(仅沿用 Supabase OTP + session)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 忘记密码的可落地后端方案(对外两步)
|
||||
|
||||
### 1.1 约束说明(关键)
|
||||
|
||||
当前 Python SDK 的 `verify_otp` 参数模型不支持在验码时直接携带 `new_password`。因此不能走“单接口验码并改密”的实现。
|
||||
|
||||
### 1.2 可执行流程
|
||||
|
||||
对客户端暴露两步流程,第二步在后端内部执行两段动作:
|
||||
|
||||
1. `POST /auth/password-reset`:调用 Supabase `reset_password_email` 发送 recovery 验证码。
|
||||
2. `POST /auth/password-reset/confirm`:接收 `email + token + new_password`,后端内部先调用 `verify_otp(type="recovery")`,再基于该会话调用 `update_user(password=...)`。
|
||||
|
||||
这样既匹配 SDK 能力(`verify_otp` 不支持直接带 `new_password`),又保持前端体验为两步。
|
||||
|
||||
### 1.3 API 设计
|
||||
|
||||
#### POST /auth/password-reset
|
||||
|
||||
发送重置验证码。
|
||||
|
||||
Request
|
||||
```json
|
||||
{
|
||||
"email": "string(email)",
|
||||
"redirect_to": "string(optional)"
|
||||
}
|
||||
```
|
||||
|
||||
Response: `204 No Content`
|
||||
|
||||
Errors:
|
||||
- `422` 参数错误
|
||||
- `429` 频率受限
|
||||
|
||||
#### POST /auth/password-reset/confirm
|
||||
|
||||
验证 recovery 验证码并完成改密。
|
||||
|
||||
Request
|
||||
```json
|
||||
{
|
||||
"email": "string(email)",
|
||||
"token": "string(6 digits)",
|
||||
"new_password": "string(min 6)"
|
||||
}
|
||||
```
|
||||
|
||||
Response: `204 No Content`
|
||||
|
||||
Errors:
|
||||
- `401` 验证码无效或过期
|
||||
- `422` 参数错误
|
||||
- `429` 频率受限
|
||||
|
||||
### 1.4 安全边界
|
||||
|
||||
- 用户档案更新走 `users` 域(`/users/me` -> `UserService` -> `Profile`),仅允许公开资料字段。
|
||||
- 密码修改走 `auth` 域(Supabase Auth),不复用 `users` service/repository。
|
||||
- `POST /auth/password-reset/confirm` 必须在同一请求内完成“验码 + 改密”,禁止单独暴露“仅改密”接口。
|
||||
- 即使伪造 `/users/me` 请求,也无法触发密码修改路径。
|
||||
|
||||
---
|
||||
|
||||
## 2. 前端忘记密码流程
|
||||
|
||||
流程:
|
||||
|
||||
`登录页` -> `忘记密码页(输入邮箱)` -> `验证码+新密码页` -> `返回登录并用新密码登录`
|
||||
|
||||
关键点:
|
||||
- 第二步页面一次提交 `email + token + new_password` 到 `/auth/password-reset/confirm`。
|
||||
- 所有用户反馈统一使用 `Toast`(遵循 `apps/AGENTS.md`)。
|
||||
- 错误提示优先展示后端 `detail`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 注册 UX 优化
|
||||
|
||||
### 3.1 验证码发送提示
|
||||
|
||||
在 `register_verification_screen.dart` 首次进入页面显示:
|
||||
|
||||
`验证码已发送,如未收到请检查垃圾邮件或确认邮箱已注册`
|
||||
|
||||
### 3.2 邀请码输入
|
||||
|
||||
在注册页新增可选字段:
|
||||
- Label: `邀请码(选填)`
|
||||
- Hint: `请输入邀请码`
|
||||
|
||||
前端请求体可携带 `invite_code`,后端忽略该字段,不返回错误。
|
||||
|
||||
---
|
||||
|
||||
## 4. 用户搜索 Bug 修复
|
||||
|
||||
### 4.1 问题
|
||||
|
||||
`GET /users/{username}` 隐含“用户名唯一”假设,实际不成立。
|
||||
|
||||
### 4.2 方案
|
||||
|
||||
后端删除 `GET /users/{username}`,改为 `POST /users/search`。
|
||||
|
||||
Request
|
||||
```json
|
||||
{
|
||||
"query": "string(1-100)"
|
||||
}
|
||||
```
|
||||
|
||||
Response
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "string",
|
||||
"username": "string",
|
||||
"avatar_url": "string|null",
|
||||
"bio": "string|null"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
查询策略:
|
||||
- username: 模糊匹配(`ilike`)
|
||||
- email: 精确匹配
|
||||
- 最多返回 20 条
|
||||
- 返回公开字段,不返回 email
|
||||
|
||||
### 4.3 前端联动
|
||||
|
||||
必须同步迁移 `apps/lib/features/users/data/*`(`getByUsername` -> `searchUsers`),否则删除后端旧路由后前端会直接 404。
|
||||
|
||||
---
|
||||
|
||||
## 5. 主要改动文件
|
||||
|
||||
### 后端
|
||||
|
||||
- `backend/src/v1/auth/schemas.py`
|
||||
- `backend/src/v1/auth/service.py`
|
||||
- `backend/src/v1/auth/gateway.py`
|
||||
- `backend/src/v1/auth/router.py`
|
||||
- `backend/src/v1/users/schemas.py`
|
||||
- `backend/src/v1/users/repository.py`
|
||||
- `backend/src/v1/users/service.py`
|
||||
- `backend/src/v1/users/router.py`
|
||||
- `backend/tests/integration/test_auth_routes.py`
|
||||
- `backend/tests/integration/test_users_routes.py`
|
||||
|
||||
### 前端
|
||||
|
||||
- `apps/lib/features/auth/ui/screens/login_screen.dart`
|
||||
- `apps/lib/features/auth/ui/screens/register_screen.dart`
|
||||
- `apps/lib/features/auth/ui/screens/register_verification_screen.dart`
|
||||
- `apps/lib/features/auth/ui/screens/forgot_password_screen.dart`(新增)
|
||||
- `apps/lib/features/auth/ui/screens/reset_password_screen.dart`(新增)
|
||||
- `apps/lib/features/auth/presentation/cubits/forgot_password_cubit.dart`(新增)
|
||||
- `apps/lib/features/auth/presentation/cubits/reset_password_cubit.dart`(新增)
|
||||
- `apps/lib/features/auth/data/auth_api.dart`
|
||||
- `apps/lib/features/auth/data/auth_repository.dart`
|
||||
- `apps/lib/features/auth/data/auth_repository_impl.dart`
|
||||
- `apps/lib/features/users/data/users_api.dart`
|
||||
- `apps/lib/features/users/data/users_repository.dart`
|
||||
- `apps/lib/features/users/data/users_repository_impl.dart`
|
||||
- `apps/lib/core/router/app_router.dart`
|
||||
|
||||
### 文档
|
||||
|
||||
- `docs/runtime/runtime-route.md`(按 AGENTS 规则必须同步)
|
||||
|
||||
---
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
- [ ] 注册验证码页首次进入显示提示。
|
||||
- [ ] 登录页出现“忘记密码”入口。
|
||||
- [ ] 忘记密码流程可完整走通(发码、确认改密、重新登录)。
|
||||
- [ ] 注册页可输入邀请码且不影响注册。
|
||||
- [ ] `GET /users/{username}` 被移除。
|
||||
- [ ] `POST /users/search` 可用且返回不含 email。
|
||||
- [ ] 后端与前端相关测试通过,文档已同步。
|
||||
@@ -1,402 +0,0 @@
|
||||
# Auth UX Enhancement Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 以最小风险完成 Auth UX 优化(忘记密码、注册提示、邀请码、用户搜索修复),并确保与现有 Supabase SDK 能力完全一致。
|
||||
|
||||
**Architecture:** 对客户端暴露两步重置流程:`password-reset` 发码 + `password-reset/confirm` 确认改密。`confirm` 在后端内部串行执行 `verify_otp(type="recovery")` 与 `update_user(password=...)`,既符合 SDK 限制又保持交互简洁。用户查询从 `GET /users/{username}` 迁移到 `POST /users/search`,并同步前端数据层,避免断链。全流程按 TDD 执行并同步路由文档。
|
||||
|
||||
**Tech Stack:** FastAPI, Pydantic, SQLAlchemy, Supabase Auth, Flutter, Dio, Bloc/Cubit
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: 基线与保护
|
||||
|
||||
### Task 1: 建立基线测试(RED 前准备)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/tests/integration/test_auth_routes.py`
|
||||
- Modify: `backend/tests/integration/test_users_routes.py`
|
||||
|
||||
**Step 1: 新增失败测试占位(不改实现)**
|
||||
|
||||
新增并期望失败的测试:
|
||||
- `test_password_reset_request_returns_204`
|
||||
- `test_password_reset_confirm_returns_204`
|
||||
- `test_search_users_returns_list`
|
||||
|
||||
**Step 2: 运行后端测试确认 RED**
|
||||
|
||||
Run: `uv run pytest backend/tests/integration/test_auth_routes.py backend/tests/integration/test_users_routes.py -v`
|
||||
|
||||
Expected: 新增用例失败(404/AttributeError/未实现)。
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/tests/integration/test_auth_routes.py backend/tests/integration/test_users_routes.py
|
||||
git commit -m "test: add failing tests for auth ux enhancement"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 后端密码重置(对外两步)
|
||||
|
||||
### Task 2: 完成密码重置请求接口(发码)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/v1/auth/schemas.py`
|
||||
- Modify: `backend/src/v1/auth/service.py`
|
||||
- Modify: `backend/src/v1/auth/gateway.py`
|
||||
- Modify: `backend/src/v1/auth/router.py`
|
||||
- Test: `backend/tests/integration/test_auth_routes.py`
|
||||
|
||||
**Step 1: 写失败测试(若 Task 1 未覆盖细节)**
|
||||
|
||||
确保 `POST /api/v1/auth/password-reset`:
|
||||
- 合法邮箱返回 `204`
|
||||
- 非法参数返回 `422`
|
||||
- 触发限流返回 `429`
|
||||
|
||||
**Step 2: 跑单测确认失败**
|
||||
|
||||
Run: `uv run pytest backend/tests/integration/test_auth_routes.py::test_password_reset_request_returns_204 -v`
|
||||
|
||||
Expected: FAIL。
|
||||
|
||||
**Step 3: 最小实现(GREEN)**
|
||||
|
||||
- `schemas.py`: 复用已有 `PasswordResetRequest`,不要重复定义同名模型。
|
||||
- `service.py`: 增加 `request_password_reset(...)`。
|
||||
- `gateway.py`: 调用 `self._client.auth.reset_password_email(email, options)`。
|
||||
- `router.py`: 新增 `POST /auth/password-reset`,返回 `204`,保留限流。
|
||||
|
||||
**Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `uv run pytest backend/tests/integration/test_auth_routes.py::test_password_reset_request_returns_204 -v`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/src/v1/auth/schemas.py backend/src/v1/auth/service.py backend/src/v1/auth/gateway.py backend/src/v1/auth/router.py backend/tests/integration/test_auth_routes.py
|
||||
git commit -m "feat(auth): add password reset request endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 完成密码重置确认接口(验码 + 改密)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/v1/auth/schemas.py`
|
||||
- Modify: `backend/src/v1/auth/service.py`
|
||||
- Modify: `backend/src/v1/auth/gateway.py`
|
||||
- Modify: `backend/src/v1/auth/router.py`
|
||||
- Test: `backend/tests/integration/test_auth_routes.py`
|
||||
|
||||
**Step 1: 写失败测试**
|
||||
|
||||
测试 `POST /api/v1/auth/password-reset/confirm`:
|
||||
- 正确 `email + token + new_password` 返回 `204`
|
||||
- 错误验证码返回 `401`
|
||||
- 弱密码/参数错误返回 `422`
|
||||
|
||||
**Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `uv run pytest backend/tests/integration/test_auth_routes.py::test_password_reset_confirm_returns_204 -v`
|
||||
|
||||
Expected: FAIL。
|
||||
|
||||
**Step 3: 最小实现**
|
||||
|
||||
- `schemas.py`: 新增 `PasswordResetConfirmRequest(email, token, new_password)`。
|
||||
- `service.py`: 增加 `confirm_password_reset(...)`。
|
||||
- `gateway.py`: 在单个方法内先调用 `verify_otp({"type":"recovery", ...})`,再在该会话上下文调用 `update_user({"password": new_password})`。
|
||||
- `router.py`: 新增 `POST /auth/password-reset/confirm`,返回 `204`。
|
||||
|
||||
**Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `uv run pytest backend/tests/integration/test_auth_routes.py::test_password_reset_confirm_returns_204 -v`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/src/v1/auth/schemas.py backend/src/v1/auth/service.py backend/src/v1/auth/gateway.py backend/src/v1/auth/router.py backend/tests/integration/test_auth_routes.py
|
||||
git commit -m "feat(auth): add password reset confirm endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 后端用户搜索替代用户名路由
|
||||
|
||||
### Task 4: 新增 POST /users/search 并移除旧路由
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/v1/users/schemas.py`
|
||||
- Modify: `backend/src/v1/users/repository.py`
|
||||
- Modify: `backend/src/v1/users/service.py`
|
||||
- Modify: `backend/src/v1/users/router.py`
|
||||
- Test: `backend/tests/integration/test_users_routes.py`
|
||||
|
||||
**Step 1: 写失败测试**
|
||||
|
||||
新增测试:
|
||||
- `POST /api/v1/users/search` 成功返回列表
|
||||
- `query` 为空返回 `422`
|
||||
- 删除后 `GET /api/v1/users/{username}` 返回 `404`
|
||||
|
||||
**Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `uv run pytest backend/tests/integration/test_users_routes.py -v`
|
||||
|
||||
Expected: FAIL。
|
||||
|
||||
**Step 3: 最小实现**
|
||||
|
||||
- `schemas.py`: 增加 `UserSearchRequest`, `UserSearchResult`。
|
||||
- `repository.py`: 新增 `search_users(query)`;username `ilike` + email 精确匹配,`limit 20`。
|
||||
- `service.py`: 增加 `search_users(...)` 并映射公开字段。
|
||||
- `router.py`: 增加 `POST /users/search`;删除 `GET /users/{username}`。
|
||||
|
||||
**Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `uv run pytest backend/tests/integration/test_users_routes.py -v`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/src/v1/users/schemas.py backend/src/v1/users/repository.py backend/src/v1/users/service.py backend/src/v1/users/router.py backend/tests/integration/test_users_routes.py
|
||||
git commit -m "refactor(users): replace username endpoint with search"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 前端认证 UX
|
||||
|
||||
### Task 5: 数据层支持密码重置两步接口
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/lib/features/auth/data/models/login_request.dart`
|
||||
- Modify: `apps/lib/features/auth/data/auth_api.dart`
|
||||
- Modify: `apps/lib/features/auth/data/auth_repository.dart`
|
||||
- Modify: `apps/lib/features/auth/data/auth_repository_impl.dart`
|
||||
- Test: `apps/test/features/auth/data/auth_repository_impl_test.dart`(如不存在则创建)
|
||||
|
||||
**Step 1: 写失败测试**
|
||||
|
||||
覆盖:
|
||||
- request -> confirm 的调用顺序
|
||||
- confirm 请求包含 `email + token + new_password`
|
||||
|
||||
**Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `flutter test apps/test/features/auth/data/auth_repository_impl_test.dart`
|
||||
|
||||
Expected: FAIL。
|
||||
|
||||
**Step 3: 最小实现**
|
||||
|
||||
- 增加 `requestPasswordReset` / `confirmPasswordReset`。
|
||||
- 模型保持 snake_case JSON 键与后端一致。
|
||||
|
||||
**Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `flutter test apps/test/features/auth/data/auth_repository_impl_test.dart`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/lib/features/auth/data apps/test/features/auth/data/auth_repository_impl_test.dart
|
||||
git commit -m "feat(auth): add password reset data layer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 新增忘记密码页面与状态管理
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/lib/features/auth/ui/screens/forgot_password_screen.dart`
|
||||
- Create: `apps/lib/features/auth/ui/screens/reset_password_screen.dart`
|
||||
- Create: `apps/lib/features/auth/presentation/cubits/forgot_password_cubit.dart`
|
||||
- Create: `apps/lib/features/auth/presentation/cubits/reset_password_cubit.dart`
|
||||
- Modify: `apps/lib/features/auth/ui/screens/login_screen.dart`
|
||||
- Modify: `apps/lib/core/router/app_router.dart`
|
||||
- Test: `apps/test/features/auth/ui/forgot_password_screen_test.dart`(可新建)
|
||||
|
||||
**Step 1: 写失败测试**
|
||||
|
||||
至少覆盖:
|
||||
- 登录页点击“忘记密码”可跳转
|
||||
- 忘记密码页提交后进入验证码改密页
|
||||
|
||||
**Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `flutter test apps/test/features/auth/ui/forgot_password_screen_test.dart`
|
||||
|
||||
Expected: FAIL。
|
||||
|
||||
**Step 3: 最小实现**
|
||||
|
||||
- 使用 `Toast` 呈现提交成功/失败反馈。
|
||||
- reset 页面提交时调用单个 confirm 接口完成验码与改密。
|
||||
- 成功后跳回登录并提示“密码已重置”。
|
||||
|
||||
**Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `flutter test apps/test/features/auth/ui/forgot_password_screen_test.dart`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/lib/features/auth apps/lib/core/router/app_router.dart apps/test/features/auth/ui/forgot_password_screen_test.dart
|
||||
git commit -m "feat(auth): add forgot password ui flow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 注册体验优化(提示 + 邀请码)
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/lib/features/auth/ui/screens/register_verification_screen.dart`
|
||||
- Modify: `apps/lib/features/auth/ui/screens/register_screen.dart`
|
||||
- Modify: `apps/lib/features/auth/presentation/cubits/register_cubit.dart`
|
||||
- Modify: `apps/lib/features/auth/data/models/signup_request.dart`
|
||||
- Test: `apps/test/features/auth/ui/register_screen_test.dart`(如不存在则创建)
|
||||
|
||||
**Step 1: 写失败测试**
|
||||
|
||||
覆盖:
|
||||
- 验证码页首次进入显示提示 Toast
|
||||
- 注册页存在邀请码输入并为可选
|
||||
|
||||
**Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `flutter test apps/test/features/auth/ui/register_screen_test.dart`
|
||||
|
||||
Expected: FAIL。
|
||||
|
||||
**Step 3: 最小实现**
|
||||
|
||||
- `signup_request` 可选字段 `invite_code`。
|
||||
- 页面展示邀请码输入框,不做必填校验。
|
||||
|
||||
**Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `flutter test apps/test/features/auth/ui/register_screen_test.dart`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/lib/features/auth/ui/screens/register_verification_screen.dart apps/lib/features/auth/ui/screens/register_screen.dart apps/lib/features/auth/presentation/cubits/register_cubit.dart apps/lib/features/auth/data/models/signup_request.dart apps/test/features/auth/ui/register_screen_test.dart
|
||||
git commit -m "feat(auth): improve verification hint and invite code input"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 前端 users 数据层迁移
|
||||
|
||||
### Task 8: 将 getByUsername 迁移为 searchUsers
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/lib/features/users/data/users_api.dart`
|
||||
- Modify: `apps/lib/features/users/data/users_repository.dart`
|
||||
- Modify: `apps/lib/features/users/data/users_repository_impl.dart`
|
||||
- Test: `apps/test/features/users/data/users_repository_test.dart`(如不存在则创建)
|
||||
|
||||
**Step 1: 写失败测试**
|
||||
|
||||
覆盖:
|
||||
- `searchUsers(query)` 发送 `POST /api/v1/users/search`
|
||||
- 返回列表模型映射正确
|
||||
|
||||
**Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `flutter test apps/test/features/users/data/users_repository_test.dart`
|
||||
|
||||
Expected: FAIL。
|
||||
|
||||
**Step 3: 最小实现**
|
||||
|
||||
- 删除 `getByUsername`。
|
||||
- 新增 `searchUsers(String query)`。
|
||||
|
||||
**Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `flutter test apps/test/features/users/data/users_repository_test.dart`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/lib/features/users/data apps/test/features/users/data/users_repository_test.dart
|
||||
git commit -m "refactor(users): migrate client to search endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: 文档与全量验证
|
||||
|
||||
### Task 9: 更新 API 文档并完成全量检查
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/runtime/runtime-route.md`
|
||||
|
||||
**Step 1: 更新路由文档**
|
||||
|
||||
- 新增:
|
||||
- `POST /auth/password-reset`
|
||||
- `POST /auth/password-reset/confirm`
|
||||
- `POST /users/search`
|
||||
- 删除:
|
||||
- `GET /users/{username}`
|
||||
|
||||
文档需包含:请求/响应 schema、状态码、错误格式(RFC 7807)。
|
||||
|
||||
**Step 2: 跑后端验证**
|
||||
|
||||
Run: `uv run pytest backend/tests -v && uv run basedpyright backend/src`
|
||||
|
||||
Expected: 全通过。
|
||||
|
||||
**Step 3: 跑前端验证**
|
||||
|
||||
Run: `flutter analyze apps/lib && flutter test`
|
||||
|
||||
Expected: 全通过。
|
||||
|
||||
**Step 4: 手动验收**
|
||||
|
||||
- 忘记密码完整链路(发码/确认改密/登录)
|
||||
- 注册页邀请码与验证码提示
|
||||
- `POST /users/search` 返回结果正确
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/runtime/runtime-route.md
|
||||
git commit -m "docs: sync runtime routes for auth ux enhancement"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验收清单
|
||||
|
||||
- [ ] 所有新增测试先失败后通过(有 RED/GREEN 记录)
|
||||
- [ ] 后端密码重置两步接口可用(confirm 内部完成验码 + 改密)
|
||||
- [ ] 前端忘记密码流程可用
|
||||
- [ ] 邀请码输入为选填且不破坏现有注册
|
||||
- [ ] `GET /users/{username}` 全链路移除
|
||||
- [ ] `POST /users/search` 前后端一致
|
||||
- [ ] `docs/runtime/runtime-route.md` 已同步
|
||||
@@ -0,0 +1,191 @@
|
||||
# Design: Schedule Items API
|
||||
|
||||
**Date:** 2026-02-27
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
实现日历事项(Schedule Items)的后端 CRUD API,支持用户创建、查询、更新、删除日历事项。
|
||||
|
||||
## Scope
|
||||
|
||||
- 仅后端 API,不涉及前端
|
||||
- 全量 CRUD
|
||||
- 查询按时间范围筛选
|
||||
- 暂不支持重复日程(recurrence_rule 留空)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. 创建日历事项
|
||||
|
||||
```
|
||||
POST /api/v1/schedule-items
|
||||
```
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"title": "string (1-255 chars, required)",
|
||||
"description": "string? (max 2000 chars)",
|
||||
"start_at": "string (ISO 8601 datetime, required)",
|
||||
"end_at": "string? (ISO 8601 datetime, must be after start_at)",
|
||||
"timezone": "string? (default: UTC)",
|
||||
"metadata": {
|
||||
"color": "#FF6B6B",
|
||||
"location": "会议室A",
|
||||
"notes": "记得带身份证",
|
||||
"attachments": [],
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** 201 Created
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "string",
|
||||
"description": "string?",
|
||||
"start_at": "string",
|
||||
"end_at": "string?",
|
||||
"timezone": "string",
|
||||
"metadata": {...},
|
||||
"status": "active",
|
||||
"source_type": "manual",
|
||||
"created_at": "string",
|
||||
"updated_at": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 查询日历事项列表
|
||||
|
||||
```
|
||||
GET /api/v1/schedule-items?start_at=2026-02-01&end_at=2026-02-28
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
- `start_at`: ISO 8601 date/datetime(查询范围起始)
|
||||
- `end_at`: ISO 8601 date/datetime(查询范围结束)
|
||||
|
||||
**Response:** 200 OK
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "string",
|
||||
"start_at": "string",
|
||||
"end_at": "string?",
|
||||
"timezone": "string",
|
||||
"status": "active"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 3. 获取单个事项
|
||||
|
||||
```
|
||||
GET /api/v1/schedule-items/{id}
|
||||
```
|
||||
|
||||
**Response:** 200 OK(完整字段,同创建响应)
|
||||
|
||||
### 4. 更新事项
|
||||
|
||||
```
|
||||
PATCH /api/v1/schedule-items/{id}
|
||||
```
|
||||
|
||||
**Request:** 支持 `title`/`description`/`start_at`/`end_at`/`timezone`/`metadata`/`status` 部分更新
|
||||
|
||||
**Response:** 200 OK
|
||||
|
||||
### 5. 删除事项
|
||||
|
||||
```
|
||||
DELETE /api/v1/schedule-items/{id}
|
||||
```
|
||||
|
||||
**Response:** 204 No Content(软删除)
|
||||
|
||||
## Data Models
|
||||
|
||||
### Metadata 结构(Pydantic)
|
||||
|
||||
```python
|
||||
from enum import Enum
|
||||
from pydantic import BaseModel
|
||||
from uuid import UUID
|
||||
|
||||
class AttachmentType(str, Enum):
|
||||
DOCUMENT = "document"
|
||||
REMINDER = "reminder"
|
||||
|
||||
class ScheduleItemMetadataAttachment(BaseModel):
|
||||
name: str
|
||||
type: AttachmentType
|
||||
visible_to: list[UUID] = []
|
||||
# document 类型
|
||||
url: str | None = None
|
||||
note: str | None = None
|
||||
# reminder 类型
|
||||
content: str | None = None
|
||||
|
||||
class ScheduleItemMetadata(BaseModel):
|
||||
color: str | None = None
|
||||
location: str | None = None
|
||||
notes: str | None = None
|
||||
attachments: list[ScheduleItemMetadataAttachment] = []
|
||||
version: int = 1
|
||||
```
|
||||
|
||||
### 数据库模型(已有)
|
||||
|
||||
参见 `backend/src/models/schedule_items.py`:
|
||||
- `id`: UUID
|
||||
- `owner_id`: UUID
|
||||
- `title`: String(255)
|
||||
- `description`: Text
|
||||
- `start_at`: DateTime(timezone=True)
|
||||
- `end_at`: DateTime(timezone=True)
|
||||
- `timezone`: String(50)
|
||||
- `extra_metadata`: JSONB (mapped as "metadata")
|
||||
- `recurrence_rule`: String(255)
|
||||
- `source_type`: Enum (MANUAL/IMPORTED/AGENT_GENERATED)
|
||||
- `status`: Enum (ACTIVE/COMPLETED/CANCELED/ARCHIVED)
|
||||
- `created_by`: UUID
|
||||
|
||||
## Architecture
|
||||
|
||||
遵循项目 `schemas / repository / service / router` 分层模式:
|
||||
|
||||
```
|
||||
backend/src/v1/schedule_items/
|
||||
├── __init__.py
|
||||
├── schemas.py # Pydantic 请求/响应模型
|
||||
├── repository.py # CRUD 操作(无 auth,无 commit)
|
||||
├── service.py # 业务逻辑 + 授权 + 事务边界
|
||||
├── router.py # FastAPI 路由定义
|
||||
└── dependencies.py # DI(如有)
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
- 所有端点需要认证(JWT)
|
||||
- `owner_id` 从 JWT `sub` 提取,不从请求体读取
|
||||
- 用户只能操作自己的日历事项(`owner_id` 过滤)
|
||||
- RLS 已在数据库层启用(防御边界)
|
||||
|
||||
## Error Handling
|
||||
|
||||
使用 RFC 7807 `application/problem+json` 格式:
|
||||
- 400: 请求参数无效
|
||||
- 401: 未认证
|
||||
- 404: 事项不存在或无权限访问
|
||||
- 422: 验证失败
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 重复日程(recurrence_rule)
|
||||
- 日程订阅与协作(schedule_subscriptions)
|
||||
- 待办事项联动(todos/todo_sources)
|
||||
- 前端实现
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
# Frontend Runtime Runbook
|
||||
|
||||
**Date:** 2026-02-27
|
||||
**Status:** Active
|
||||
**Audience:** 前端开发
|
||||
|
||||
---
|
||||
|
||||
## 开发环境
|
||||
|
||||
### Mock 模式
|
||||
|
||||
前端开发时可通过 `--dart-define` 切换 Mock 模式,无需后端即可运行:
|
||||
|
||||
```bash
|
||||
# Mock 模式(本地开发,无需后端)
|
||||
flutter run --dart-define=MOCK_API=true
|
||||
|
||||
# 正式模式(需要后端运行)
|
||||
flutter run
|
||||
```
|
||||
|
||||
### Mock 自动登录
|
||||
|
||||
Mock 模式下,启动 App 时会自动使用测试账号登录并跳转到首页。
|
||||
|
||||
**测试账号(Mock):**
|
||||
|
||||
| 场景 | 邮箱 | 密码 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 正常登录 | 任意非 error@test.com | 任意 | 登录成功 |
|
||||
| 登录失败 | error@test.com | 任意 | 返回 401 |
|
||||
|
||||
**验证码:** 任意 6 位数字(建议使用 `123456`)
|
||||
|
||||
---
|
||||
|
||||
## 打包构建
|
||||
|
||||
### Debug Build
|
||||
|
||||
```bash
|
||||
# Mock 模式
|
||||
flutter build apk --debug --dart-define=MOCK_API=true
|
||||
|
||||
# 正式模式
|
||||
flutter build apk --debug
|
||||
```
|
||||
|
||||
### Release Build
|
||||
|
||||
Release 构建强制使用正式 API,不受 `MOCK_API` 影响:
|
||||
|
||||
```bash
|
||||
flutter build apk --release
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 调试运行
|
||||
|
||||
### 命令行调试
|
||||
|
||||
```bash
|
||||
# Mock 模式(无需后端,自动登录)
|
||||
flutter run --dart-define=MOCK_API=true -d emulator-5554
|
||||
|
||||
# 正式模式(需要后端运行)
|
||||
flutter run -d emulator-5554
|
||||
```
|
||||
|
||||
### VSCode 调试配置
|
||||
|
||||
在 `.vscode/launch.json` 中添加配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Mock Mode",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"args": ["--dart-define=MOCK_API=true"]
|
||||
},
|
||||
{
|
||||
"name": "正式模式",
|
||||
"request": "launch",
|
||||
"type": "dart"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
配置完成后,在 VSCode 左侧 Debug 面板的 dropdown 中选择 "Mock Mode" 或 "正式模式" 进行调试。
|
||||
|
||||
---
|
||||
|
||||
## Change Log
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-02-27 | 新增 Frontend Runbook,支持 --dart-define=MOCK_API=true 切换 Mock 模式 |
|
||||
Reference in New Issue
Block a user