refactor: 梳理规则体系并统一记忆与部署流程
This commit is contained in:
+37
-198
@@ -1,218 +1,57 @@
|
||||
# Flutter Mobile Development Constraints
|
||||
# Apps Domain Rules
|
||||
|
||||
This document defines **hard constraints** for Flutter mobile development. Treat all items as **non-negotiable** unless explicitly overridden.
|
||||
This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable.
|
||||
|
||||
## 0) Scope and Precedence (MUST)
|
||||
## Scope & Precedence
|
||||
|
||||
- This file applies to all changes under `apps/**`.
|
||||
- It extends root routing rules in `AGENTS.md` and workspace global runtime rules.
|
||||
- It also incorporates the visual design language from `apps/rules/visual_design_language.md` as a binding constraint.
|
||||
- If rules conflict, apply the stricter requirement.
|
||||
- Keep Flutter-specific constraints in this file; avoid duplicating them in root `AGENTS.md`.
|
||||
- Inherits root `AGENTS.md` and workspace runtime rules.
|
||||
- If rules conflict, apply the stricter one.
|
||||
- Visual language source of truth: `apps/rules/visual_design_language.md`.
|
||||
|
||||
## 1) Design Tokens (MUST)
|
||||
## UI Design System (Must)
|
||||
|
||||
- **MUST** use design tokens from `apps/lib/core/theme/design_tokens.dart`:
|
||||
- Colors: `AppColors.*`
|
||||
- Spacing: `AppSpacing.*`
|
||||
- Radius: `AppRadius.*`
|
||||
- **MUST NOT** hardcode any visual values.
|
||||
- Design tokens are the single source of truth for all visual values. Any missing visual semantics should be added to tokens, not approximated locally.
|
||||
- This ensures consistency with the visual design language defined in `apps/rules/visual_design_language.md`.
|
||||
- Use design tokens only from `apps/lib/core/theme/design_tokens.dart` (`AppColors`, `AppSpacing`, `AppRadius`).
|
||||
- No hardcoded visual values.
|
||||
- If semantics are missing, add token definitions first.
|
||||
|
||||
## 2) Component Architecture (MUST)
|
||||
## Reuse & Composition (Must)
|
||||
|
||||
- **SHOULD** extract repeated UI patterns into reusable components.
|
||||
- **SHOULD** prefer existing shared components before creating new ones.
|
||||
- **SHOULD** place reusable components in `apps/lib/shared/widgets/` following existing naming conventions.
|
||||
- **MUST NOT** introduce parallel UI systems (e.g., custom button styles, custom loading indicators) that duplicate existing shared components.
|
||||
- When creating new UI components, ensure they follow the design tokens and visual design language.
|
||||
- Prefer `apps/lib/shared/widgets/` before adding new components.
|
||||
- Extract repeated page structures/components; do not duplicate sibling-page scaffolds.
|
||||
- Detail page top-right actions must use shared action-menu components.
|
||||
- Destructive confirmations must use project-consistent shared surfaces.
|
||||
|
||||
## 2.1) Navigation/Header Reuse Rules (MUST)
|
||||
## Interaction & Feedback (Must)
|
||||
|
||||
- For page groups with clear parent-child relationships (e.g., Settings and its subpages), **MUST** use one shared header pattern: back button + page title.
|
||||
- **MUST** extract shared page scaffolds/header wrappers instead of duplicating `SafeArea + header + scroll` structures across sibling pages.
|
||||
- Detail-page right-side actions (edit/delete/share etc.) **MUST** use a shared action-menu component, not per-page ad-hoc button groups.
|
||||
- Header action menus **MUST NOT** overlap the trigger button area; menu surfaces should open below/right-aligned to the trigger and preserve title readability.
|
||||
- User feedback: `Toast` / `AppBanner` only.
|
||||
- Loading indicators: `AppLoadingIndicator` only.
|
||||
- Form pages should default to keyboard-overlay behavior to avoid full-page layout jumps.
|
||||
|
||||
## 2.2) Interaction Surface Reuse Rules (MUST)
|
||||
## Agent Chat Protocol (Must)
|
||||
|
||||
- Repeated state-switch controls (toggle/switch UI) **MUST** be extracted into shared widgets.
|
||||
- Destructive confirmations (delete/remove) **MUST** use shared project-style confirmation surfaces (e.g., unified action sheet), not platform-default dialog styles.
|
||||
- **MUST NOT** use raw platform-default popup/dialog/dropdown visuals when they break project visual language; use token-driven shared components instead.
|
||||
- Agent chat must follow AG-UI over SSE.
|
||||
- Lifecycle events are mandatory: `RUN_STARTED` and exactly one of `RUN_FINISHED` or `RUN_ERROR`.
|
||||
- Text streaming flow must be `TEXT_MESSAGE_START -> TEXT_MESSAGE_CONTENT -> TEXT_MESSAGE_END`.
|
||||
|
||||
## 3) Layout Mapping & Alignment (MUST)
|
||||
## High-Risk Modules (Must)
|
||||
|
||||
- **MUST** explicitly set `crossAxisAlignment` for every `Row` / `Column` (do not rely on defaults).
|
||||
- **MUST** preserve layout semantics from root to leaf:
|
||||
- alignment/justification intent must be explicitly represented in Flutter widgets.
|
||||
- **MUST NOT** skip necessary container layers if doing so loses layout meaning or makes mapping non-traceable.
|
||||
### Auth
|
||||
|
||||
## 4) Centering & Visual Balance (MUST)
|
||||
- `AuthBloc` is the single source of truth.
|
||||
- 401 invalidation must go through global callback chain; no feature-level token clearing or direct login navigation.
|
||||
|
||||
- **MUST** evaluate centering within `SafeArea` usable bounds (not full-screen bounds).
|
||||
- **MUST NOT** rely on `Spacer` / proportional flex as the only centering mechanism for critical content.
|
||||
- If persistent header/footer regions exist, **MUST** center primary content within the remaining usable region.
|
||||
- **MUST** prioritize *visual centering* over purely geometric centering when they differ.
|
||||
### Home Message Viewport
|
||||
|
||||
## 4.1) Keyboard Overlay Behavior (MUST)
|
||||
- Home message auto-scroll/anchor restore must be event-driven.
|
||||
- Preserve viewport during history prepend and when user is reading above bottom.
|
||||
|
||||
- For pages with text input, keyboard appearance **MUST** prefer overlay behavior and avoid reflowing the whole page.
|
||||
- Input pages **MUST** avoid global layout jump when keyboard opens/closes (including subtle shifts caused by safe-area padding changes).
|
||||
- Default strategy for full-screen pages with fixed hierarchy: `Scaffold.resizeToAvoidBottomInset = false`.
|
||||
- If a page truly requires keyboard-driven scrolling to keep focused fields reachable, this must be an explicit opt-in and justified by page structure.
|
||||
- When using `SafeArea` on keyboard-overlay pages, **MUST** stabilize bottom safe-area behavior (for example, maintain bottom view padding) to prevent center recalculation jitter.
|
||||
### Cache / Repository
|
||||
|
||||
## 5) Testing Strategy (MUST)
|
||||
- Reads/writes that affect consistency must go through repository layer.
|
||||
- Cache keys and invalidation policy belong to repository, not UI/Bloc.
|
||||
|
||||
Follow lightweight testing strategy - prioritize value over coverage:
|
||||
## Testing Policy
|
||||
|
||||
**Write tests for:**
|
||||
- Model / DTO parsing (json → model)
|
||||
- Service layer logic (business rules, API call handling)
|
||||
- High-regression UI interaction flows only (multi-state/multi-step widgets, viewport/scroll decision logic, route return stability)
|
||||
|
||||
**Default skip for UI tests:**
|
||||
- Simple UI pages, regular buttons, basic layouts
|
||||
- Pure visual structure/snapshot-like checks without behavior risk
|
||||
- Low-risk styling and static rendering changes
|
||||
|
||||
**UI test policy:**
|
||||
- UI tests are opt-in, not default; only keep or add them when failure risk is high and there is clear regression value.
|
||||
- If a UI test does not protect critical interaction behavior, remove it or avoid adding it.
|
||||
|
||||
## 6) UI Feedback System (MUST)
|
||||
|
||||
- All user-facing feedback **MUST** use the Toast system.
|
||||
- Transient notifications: `Toast.show(...)`
|
||||
- Persistent inline form errors: `AppBanner`
|
||||
- **MUST NOT** create custom SnackBar/Dialog/Banner feedback components.
|
||||
- **MUST NOT** use raw `ScaffoldMessenger` for feedback messaging.
|
||||
|
||||
## 6.1) Loading Indicator System (MUST)
|
||||
|
||||
- All loading spinners **MUST** use `AppLoadingIndicator` from `apps/lib/shared/widgets/app_loading_indicator.dart`.
|
||||
- **MUST NOT** use raw `CircularProgressIndicator` directly in feature/page code.
|
||||
- Use variants consistently:
|
||||
- page/surface loading: `AppLoadingVariant.surface`
|
||||
- inline small loading (list/search/section): `AppLoadingVariant.inline`
|
||||
- button loading: `AppLoadingVariant.button`
|
||||
- If visual semantics are missing, extend `AppLoadingIndicator` variant mapping first; do not create ad-hoc loading styles in feature files.
|
||||
|
||||
## 7) Agent Chat (AG-UI Protocol) (MUST)
|
||||
|
||||
Agent chat functionality **MUST** follow the AG-UI protocol. **Use the `ag-ui` skill** for protocol reference and implementation guidance.
|
||||
|
||||
- **MUST** use Server-Sent Events (SSE) for streaming.
|
||||
- **MUST** emit required lifecycle events:
|
||||
- `RUN_STARTED` is required for every run
|
||||
- End with exactly one of: `RUN_FINISHED` or `RUN_ERROR`
|
||||
- **MUST** follow standard text streaming flow:
|
||||
- `TEXT_MESSAGE_START` → `TEXT_MESSAGE_CONTENT` (delta) → `TEXT_MESSAGE_END`
|
||||
- **MUST** support the standard AG-UI event type set as defined in the spec.
|
||||
- **MUST NOT** return non-streaming responses for agent chat.
|
||||
- **MUST NOT** omit required lifecycle events.
|
||||
- **MUST NOT** use non-AG-UI event formats (except where the spec explicitly allows).
|
||||
|
||||
## 8) Visual Design Language (MUST)
|
||||
|
||||
All UI/UX work **MUST** follow the visual design language defined in `apps/rules/visual_design_language.md`.
|
||||
|
||||
- **MUST** ensure screens feel like a premium personal assistant product, not a wireframe, admin console, or document page.
|
||||
- **MUST** apply the surface-based design system (background, primary, secondary, interactive surfaces).
|
||||
- **MUST** follow the motion and interaction feel guidelines (soft, responsive, premium).
|
||||
- **MUST** achieve visual hierarchy through spacing, surface grouping, radius, depth, density, contrast, scale, and motion—not color alone.
|
||||
- **MUST** prioritize compact informational delivery in top bars: when the page purpose can be clearly expressed by a concise header title, avoid repeating equivalent explanatory hints in the body.
|
||||
- **MUST NOT** duplicate page identity text between header and first content block unless the repeated text introduces new decision-critical information.
|
||||
- **MUST** keep interface copy minimal and action-oriented: every text node must justify its presence by either enabling a decision, reducing error risk, or satisfying compliance requirements.
|
||||
- **MUST NOT** stack multiple instructional hints around the same control (for example title + subtitle + placeholder + helper text all repeating the same meaning).
|
||||
- **MUST** follow copy priority for auth and form pages: `Primary action > Required input labels > Error/recovery > Compliance`. Secondary explanatory copy should be removed when users can complete the task without it.
|
||||
- **MUST** ensure each form field has at most one primary hint source in normal state (prefer placeholder or label, not both with duplicated wording).
|
||||
- **MUST** follow the screen-level decision rules:
|
||||
1. What is the primary focus?
|
||||
2. What is the surface hierarchy?
|
||||
3. What needs strongest emphasis?
|
||||
4. What should be grouped?
|
||||
5. What should be lightweight/secondary?
|
||||
6. Where should motion reinforce understanding?
|
||||
7. How can the result feel more like a premium assistant app and less like a document page?
|
||||
- **MUST NOT** create UIs that match the anti-patterns listed in the visual design language document:
|
||||
- plain document page, white slab with blue buttons, spreadsheet-like admin panel
|
||||
- low-fidelity wireframe, default Flutter demo app, generic template marketplace screen
|
||||
- full-screen flat white blocks, arbitrary shadow usage, inconsistent card treatments
|
||||
- raw container stacking without surface semantics
|
||||
|
||||
Before finalizing any UI, mentally verify:
|
||||
- Does this feel like a product, not a page?
|
||||
- Is there clear hierarchy?
|
||||
- Do surfaces feel intentional?
|
||||
- Does the screen feel calm and premium?
|
||||
- Is the assistant identity visually present?
|
||||
- Would this look plausible in a polished shipping app?
|
||||
|
||||
## 9) Auth Global Module Rules (MUST)
|
||||
|
||||
Auth is a global module. All auth/session behavior MUST follow a single state machine.
|
||||
|
||||
- **MUST** treat `AuthBloc` as the single source of truth for authentication state.
|
||||
- **MUST NOT** implement ad-hoc auth state in feature modules (no parallel flags, no local auth caches).
|
||||
- **MUST** route all 401 refresh-failure handling through the global callback chain:
|
||||
`ApiInterceptor -> ApiClient auth failure callback -> AuthBloc(AuthSessionInvalidated)`.
|
||||
- **MUST NOT** clear tokens directly inside feature/page code.
|
||||
- **MUST NOT** navigate to login directly from feature code on token errors; rely on router redirect driven by global auth state.
|
||||
- **MUST** distinguish logout semantics:
|
||||
- manual logout: revoke server session + clear local session
|
||||
- auto expiry/logout on refresh failure: clear local session only
|
||||
- **MUST** ensure startup session recovery has exception fallback and never leaves app stuck in boot/loading state.
|
||||
- **MUST** add/maintain tests for:
|
||||
- startup recovery fallback
|
||||
- concurrent 401 refresh failure singleflight
|
||||
- session invalidation -> unauthenticated redirect path
|
||||
|
||||
If a new auth-related requirement cannot fit this model, update this section first, then implement code.
|
||||
|
||||
## 10) Home Message Loading & Scroll Rules (MUST)
|
||||
|
||||
Home 首页历史消息加载与滚动策略属于高回归模块,必须遵循以下约束:
|
||||
|
||||
- **MUST** use event-driven viewport decisions for Home message list behavior.
|
||||
- Use `HomeMessageViewportController` as the decision engine.
|
||||
- **MUST NOT** drive auto-scroll directly from `items.length` diffs or ad-hoc boolean combinations.
|
||||
- **MUST** distinguish semantic events at minimum:
|
||||
- initial history loaded
|
||||
- history prepend start/finish
|
||||
- new message appended
|
||||
- sub-route resume
|
||||
- refresh completed
|
||||
- user scroll state changed
|
||||
- **MUST** preserve reading position when user is away from bottom.
|
||||
- New messages while reading history should show unread indicator instead of forcing bottom jump.
|
||||
- **MUST** preserve viewport anchor during history prepend.
|
||||
- **MUST NOT** mix prepend restore with unconditional bottom auto-scroll.
|
||||
- **MUST** use `returnToHomePreserveState` for business-subroute returns to Home (calendar/todo/message-related flows).
|
||||
- **MUST NOT** introduce new direct business-route `go('/home')` shortcuts.
|
||||
- Auth entry flows (login/register success) are allowed to navigate to Home directly.
|
||||
- **MUST** add or update tests when touching Home message loading/scroll behavior:
|
||||
- controller-level state transition tests
|
||||
- widget-level unread indicator and scroll behavior tests
|
||||
- route-return stability tests when navigation behavior changes
|
||||
|
||||
## 11) Cache & Repository Rules (MUST)
|
||||
|
||||
前端缓存与数据访问属于高回归区域,必须遵循以下约束:
|
||||
|
||||
- **MUST** route feature data reads/writes through repository layer when cache, invalidation, or optimistic update is involved.
|
||||
- Feature/UI code **MUST NOT** call raw `*Api` methods directly for mutation paths that affect list/detail consistency.
|
||||
- Exceptions are allowed only for bootstrapping or truly stateless read operations, and must be documented in code review notes.
|
||||
- **MUST** keep cache key ownership centralized in repository classes.
|
||||
- UI/Bloc/Cubit **MUST NOT** hardcode cache keys or perform ad-hoc cache writes.
|
||||
- **MUST** define cache invalidation at mutation boundaries (create/update/delete/archive/complete/reorder).
|
||||
- Mutation success must either update cache atomically or invalidate and trigger deterministic refresh.
|
||||
- **MUST** preserve route-return consistency for data freshness.
|
||||
- Pages that mutate entity data must return an explicit changed signal to caller routes.
|
||||
- Caller list pages must consume that signal and refresh using repository path.
|
||||
- **MUST** ensure list item widgets that carry local interaction state use stable identity keys (e.g. `ValueKey(entity.id)`) to prevent state leakage across reused cells.
|
||||
- **MUST** add/maintain regression tests when changing cache/repository behavior:
|
||||
- repository tests for optimistic update + rollback + invalidation
|
||||
- route-return refresh tests for list/detail/edit flows
|
||||
- widget tests for stable keyed interaction state where applicable
|
||||
- Prioritize tests for model parsing, service logic, and high-regression interaction flows.
|
||||
- Simple static UI changes may skip tests.
|
||||
- Auth/Home/Cache changes must include targeted regression tests.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<application
|
||||
android:usesCleartextTraffic="true"
|
||||
android:label="灵可析"
|
||||
android:label="林小夕"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<receiver
|
||||
|
||||
@@ -24,6 +24,8 @@ import '../../features/todo/ui/screens/todo_edit_screen.dart';
|
||||
import '../../features/settings/ui/screens/settings_screen.dart';
|
||||
import '../../features/settings/ui/screens/features_screen.dart';
|
||||
import '../../features/settings/ui/screens/memory_screen.dart';
|
||||
import '../../features/settings/ui/screens/user_memory_view_screen.dart';
|
||||
import '../../features/settings/ui/screens/work_memory_view_screen.dart';
|
||||
import '../../features/settings/ui/screens/user_memory_detail_screen.dart';
|
||||
import '../../features/settings/ui/screens/work_memory_detail_screen.dart';
|
||||
import '../../features/settings/ui/screens/edit_profile_screen.dart';
|
||||
@@ -45,6 +47,8 @@ final _protectedRoutes = [
|
||||
AppRoutes.settingsMemory,
|
||||
AppRoutes.settingsMemoryUser,
|
||||
AppRoutes.settingsMemoryWork,
|
||||
AppRoutes.settingsMemoryUserEdit,
|
||||
AppRoutes.settingsMemoryWorkEdit,
|
||||
AppRoutes.settingsEditProfile,
|
||||
AppRoutes.messageInviteList,
|
||||
];
|
||||
@@ -182,10 +186,18 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.settingsMemoryUser,
|
||||
builder: (context, state) => const UserMemoryDetailScreen(),
|
||||
builder: (context, state) => const UserMemoryViewScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.settingsMemoryWork,
|
||||
builder: (context, state) => const WorkMemoryViewScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.settingsMemoryUserEdit,
|
||||
builder: (context, state) => const UserMemoryDetailScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.settingsMemoryWorkEdit,
|
||||
builder: (context, state) => const WorkMemoryDetailScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
|
||||
@@ -32,5 +32,7 @@ class AppRoutes {
|
||||
static const settingsMemory = '/settings/memory';
|
||||
static const settingsMemoryUser = '/settings/memory/user';
|
||||
static const settingsMemoryWork = '/settings/memory/work';
|
||||
static const settingsMemoryUserEdit = '/settings/memory/user/edit';
|
||||
static const settingsMemoryWorkEdit = '/settings/memory/work/edit';
|
||||
static const settingsEditProfile = '/edit-profile';
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import 'package:social_app/core/theme/design_tokens.dart';
|
||||
import 'package:social_app/core/router/app_routes.dart';
|
||||
import 'package:social_app/shared/widgets/app_loading_indicator.dart';
|
||||
import 'package:social_app/shared/widgets/app_pressable.dart';
|
||||
import 'package:social_app/shared/widgets/toast/toast.dart';
|
||||
import 'package:social_app/shared/widgets/toast/toast_type.dart';
|
||||
import '../widgets/settings_page_scaffold.dart';
|
||||
import '../../data/models/memory_models.dart';
|
||||
import '../../data/services/memory_service.dart';
|
||||
@@ -58,7 +56,6 @@ class _MemoryScreenState extends State<MemoryScreen> {
|
||||
return SettingsPageScaffold(
|
||||
title: '我的记忆',
|
||||
onBack: () => context.pop(),
|
||||
footer: _buildFooter(),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -393,7 +390,7 @@ class _MemoryScreenState extends State<MemoryScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'工作Profile',
|
||||
'工作画像',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -486,36 +483,4 @@ class _MemoryScreenState extends State<MemoryScreen> {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _buildFooter() {
|
||||
return AppPressable(
|
||||
onTap: () {
|
||||
Toast.show(context, '记忆会随着你的使用自动完善', type: ToastType.info);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceInfoLight,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.borderQuaternary),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 16, color: AppColors.blue500),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
const Text(
|
||||
'点击卡片查看或编辑详情',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.blue600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ class _UserMemoryDetailScreenState extends State<UserMemoryDetailScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SettingsPageScaffold(
|
||||
title: '个人偏好',
|
||||
title: '编辑个人偏好',
|
||||
onBack: () => context.pop(),
|
||||
footer: _hasChanges ? _buildSaveButton() : null,
|
||||
body: Column(
|
||||
@@ -182,7 +182,7 @@ class _UserMemoryDetailScreenState extends State<UserMemoryDetailScreen> {
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x4D60A5FA),
|
||||
color: AppColors.blue500.withValues(alpha: 0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
@@ -190,16 +190,7 @@ class _UserMemoryDetailScreenState extends State<UserMemoryDetailScreen> {
|
||||
),
|
||||
child: Center(
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppColors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
? const AppLoadingIndicator(variant: AppLoadingVariant.button)
|
||||
: const Text(
|
||||
'保存更改',
|
||||
style: TextStyle(
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:social_app/core/di/injection.dart';
|
||||
import 'package:social_app/core/router/app_routes.dart';
|
||||
import 'package:social_app/core/theme/design_tokens.dart';
|
||||
import 'package:social_app/shared/widgets/app_loading_indicator.dart';
|
||||
import 'package:social_app/shared/widgets/app_pressable.dart';
|
||||
import 'package:social_app/shared/widgets/detail_header_action_menu.dart';
|
||||
|
||||
import '../../data/models/memory_models.dart';
|
||||
import '../../data/services/memory_service.dart';
|
||||
import '../widgets/settings_page_scaffold.dart';
|
||||
|
||||
enum _UserMemoryHeaderAction { edit }
|
||||
|
||||
class UserMemoryViewScreen extends StatefulWidget {
|
||||
const UserMemoryViewScreen({super.key});
|
||||
|
||||
@override
|
||||
State<UserMemoryViewScreen> createState() => _UserMemoryViewScreenState();
|
||||
}
|
||||
|
||||
class _UserMemoryViewScreenState extends State<UserMemoryViewScreen> {
|
||||
final MemoryService _memoryService = sl<MemoryService>();
|
||||
UserMemoryContent? _memory;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadMemory();
|
||||
}
|
||||
|
||||
Future<void> _loadMemory() async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final memory = await _memoryService.getUserMemory();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_memory = memory;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = '加载失败';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onHeaderAction(_UserMemoryHeaderAction action) {
|
||||
switch (action) {
|
||||
case _UserMemoryHeaderAction.edit:
|
||||
context.push(AppRoutes.settingsMemoryUserEdit);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SettingsPageScaffold(
|
||||
title: '个人偏好',
|
||||
onBack: () => context.pop(),
|
||||
trailing: DetailHeaderActionMenu<_UserMemoryHeaderAction>(
|
||||
items: const [
|
||||
DetailHeaderActionItem<_UserMemoryHeaderAction>(
|
||||
value: _UserMemoryHeaderAction.edit,
|
||||
label: '编辑',
|
||||
icon: Icons.edit_outlined,
|
||||
),
|
||||
],
|
||||
onSelected: _onHeaderAction,
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_isLoading) ...[
|
||||
const SizedBox(height: AppSpacing.xxl * 2),
|
||||
const Center(child: AppLoadingIndicator(size: 32)),
|
||||
] else if (_error != null) ...[
|
||||
const SizedBox(height: AppSpacing.xxl * 2),
|
||||
_buildErrorState(),
|
||||
] else
|
||||
_buildContent(_memory ?? UserMemoryContent()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 48, color: AppColors.slate300),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(_error ?? '加载失败', style: TextStyle(color: AppColors.slate500)),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
AppPressable(
|
||||
onTap: _loadMemory,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue50,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: Border.all(color: AppColors.blue100),
|
||||
),
|
||||
child: const Text(
|
||||
'重新加载',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.blue600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(UserMemoryContent memory) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildSectionCard(
|
||||
title: '基本信息',
|
||||
icon: Icons.person_outline,
|
||||
children: [
|
||||
_buildInfoRow(Icons.work_outline, '职业', _text(memory.occupation)),
|
||||
_buildInfoRow(Icons.public_outlined, '时区', _text(memory.timezone)),
|
||||
_buildInfoRow(
|
||||
Icons.translate_outlined,
|
||||
'主要语言',
|
||||
_text(memory.primaryLanguage),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildSectionCard(
|
||||
title: '偏好设置',
|
||||
icon: Icons.tune,
|
||||
children: [
|
||||
_buildInfoRow(
|
||||
Icons.chat_bubble_outline,
|
||||
'沟通风格',
|
||||
_text(memory.preferences.communicationStyle),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.place_outlined,
|
||||
'位置偏好',
|
||||
_text(memory.preferences.locationPreference),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.balcony_outlined,
|
||||
'工作生活方式',
|
||||
_text(memory.preferences.workLifestyle),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.language_outlined,
|
||||
'语言偏好',
|
||||
_listText(memory.preferences.languagePreference),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.notifications_outlined,
|
||||
'通知偏好',
|
||||
_listText(memory.preferences.notificationPreference),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildSectionCard(
|
||||
title: '日程偏好',
|
||||
icon: Icons.schedule_outlined,
|
||||
children: [
|
||||
_buildInfoRow(
|
||||
Icons.timer_outlined,
|
||||
'会议缓冲时间',
|
||||
_minutesText(memory.schedulingPreferences.meetingBufferMinutes),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.format_list_numbered,
|
||||
'每日最多会议',
|
||||
_numberText(memory.schedulingPreferences.maxMeetingsPerDay),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.timelapse_outlined,
|
||||
'偏好会议时长',
|
||||
_intListText(
|
||||
memory.schedulingPreferences.preferredMeetingDurationMinutes,
|
||||
suffix: '分钟',
|
||||
),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.note_outlined,
|
||||
'备注',
|
||||
_text(memory.schedulingPreferences.notes),
|
||||
multiline: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildSectionCard(
|
||||
title: '联系人',
|
||||
icon: Icons.people_outline,
|
||||
children: [_buildPeople(memory.people)],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildSectionCard(
|
||||
title: '地点',
|
||||
icon: Icons.place_outlined,
|
||||
children: [_buildPlaces(memory.places)],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildSectionCard(
|
||||
title: '兴趣',
|
||||
icon: Icons.interests_outlined,
|
||||
children: [_buildTags(memory.interests)],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildSectionCard(
|
||||
title: '回避话题',
|
||||
icon: Icons.not_interested_outlined,
|
||||
children: [_buildTags(memory.avoidTopics)],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildSectionCard(
|
||||
title: '自定义规则',
|
||||
icon: Icons.rule_folder_outlined,
|
||||
children: [_buildTags(memory.customRules)],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildSectionCard(
|
||||
title: '周期习惯',
|
||||
icon: Icons.repeat,
|
||||
children: [_buildRoutines(memory.recurringRoutines)],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionCard({
|
||||
required String title,
|
||||
required IconData icon,
|
||||
required List<Widget> children,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue50,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Icon(icon, size: 16, color: AppColors.blue600),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(
|
||||
IconData icon,
|
||||
String label,
|
||||
String value, {
|
||||
bool multiline = false,
|
||||
}) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceSecondary,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: multiline
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: AppColors.slate500),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPeople(List<Person> people) {
|
||||
if (people.isEmpty) {
|
||||
return _buildEmptyTip('暂无联系人');
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: people.map((person) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceSecondary,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: Border.all(color: AppColors.borderTertiary),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildInfoRow(Icons.badge_outlined, '姓名', _text(person.name)),
|
||||
_buildInfoRow(
|
||||
Icons.account_tree_outlined,
|
||||
'关系',
|
||||
_text(person.relationship),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.person_pin_outlined,
|
||||
'角色',
|
||||
_text(person.role),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.phone_outlined,
|
||||
'联系方式',
|
||||
_text(person.preferredContactChannel),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.note_outlined,
|
||||
'备注',
|
||||
_text(person.notes),
|
||||
multiline: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaces(List<Place> places) {
|
||||
if (places.isEmpty) {
|
||||
return _buildEmptyTip('暂无地点');
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: places.map((place) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceSecondary,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: Border.all(color: AppColors.borderTertiary),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildInfoRow(Icons.place_outlined, '名称', _text(place.name)),
|
||||
_buildInfoRow(
|
||||
Icons.category_outlined,
|
||||
'类别',
|
||||
_text(place.category),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.favorite_border,
|
||||
'偏好',
|
||||
_text(place.preference),
|
||||
),
|
||||
_buildInfoRow(Icons.map_outlined, '地址', _text(place.address)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRoutines(List<RecurringRoutine> routines) {
|
||||
if (routines.isEmpty) {
|
||||
return _buildEmptyTip('暂无周期习惯');
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: routines.map((routine) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceSecondary,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: Border.all(color: AppColors.borderTertiary),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildInfoRow(Icons.title_outlined, '名称', _text(routine.name)),
|
||||
_buildInfoRow(
|
||||
Icons.subject_outlined,
|
||||
'描述',
|
||||
_text(routine.description),
|
||||
multiline: true,
|
||||
),
|
||||
_buildInfoRow(Icons.repeat_one, '周期', _text(routine.cadence)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTags(List<String> tags) {
|
||||
if (tags.isEmpty) {
|
||||
return _buildEmptyTip('暂无数据');
|
||||
}
|
||||
return Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: tags.map((tag) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue50,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(color: AppColors.blue100),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.label_outline, size: 14, color: AppColors.blue500),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Text(
|
||||
tag,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.blue600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyTip(String text) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceSecondary,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: Border.all(color: AppColors.borderTertiary),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.inbox_outlined, size: 16, color: AppColors.slate400),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _text(String? value) {
|
||||
final raw = value?.trim() ?? '';
|
||||
return raw.isEmpty ? '未设置' : raw;
|
||||
}
|
||||
|
||||
String _listText(List<String> values) {
|
||||
if (values.isEmpty) return '未设置';
|
||||
return values.join('、');
|
||||
}
|
||||
|
||||
String _intListText(List<int> values, {required String suffix}) {
|
||||
if (values.isEmpty) return '未设置';
|
||||
return values.map((value) => '$value$suffix').join('、');
|
||||
}
|
||||
|
||||
String _minutesText(int? value) {
|
||||
if (value == null) return '未设置';
|
||||
return '$value 分钟';
|
||||
}
|
||||
|
||||
String _numberText(int? value) {
|
||||
if (value == null) return '未设置';
|
||||
return '$value';
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ class _WorkMemoryDetailScreenState extends State<WorkMemoryDetailScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SettingsPageScaffold(
|
||||
title: '工作Profile',
|
||||
title: '编辑工作画像',
|
||||
onBack: () => context.pop(),
|
||||
footer: _hasChanges ? _buildSaveButton() : null,
|
||||
body: Column(
|
||||
@@ -182,7 +182,7 @@ class _WorkMemoryDetailScreenState extends State<WorkMemoryDetailScreen> {
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x4D60A5FA),
|
||||
color: AppColors.blue500.withValues(alpha: 0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
@@ -190,16 +190,7 @@ class _WorkMemoryDetailScreenState extends State<WorkMemoryDetailScreen> {
|
||||
),
|
||||
child: Center(
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppColors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
? const AppLoadingIndicator(variant: AppLoadingVariant.button)
|
||||
: const Text(
|
||||
'保存更改',
|
||||
style: TextStyle(
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:social_app/core/di/injection.dart';
|
||||
import 'package:social_app/core/router/app_routes.dart';
|
||||
import 'package:social_app/core/theme/design_tokens.dart';
|
||||
import 'package:social_app/shared/widgets/app_loading_indicator.dart';
|
||||
import 'package:social_app/shared/widgets/app_pressable.dart';
|
||||
import 'package:social_app/shared/widgets/detail_header_action_menu.dart';
|
||||
|
||||
import '../../data/models/memory_models.dart';
|
||||
import '../../data/services/memory_service.dart';
|
||||
import '../widgets/settings_page_scaffold.dart';
|
||||
|
||||
enum _WorkMemoryHeaderAction { edit }
|
||||
|
||||
class WorkMemoryViewScreen extends StatefulWidget {
|
||||
const WorkMemoryViewScreen({super.key});
|
||||
|
||||
@override
|
||||
State<WorkMemoryViewScreen> createState() => _WorkMemoryViewScreenState();
|
||||
}
|
||||
|
||||
class _WorkMemoryViewScreenState extends State<WorkMemoryViewScreen> {
|
||||
final MemoryService _memoryService = sl<MemoryService>();
|
||||
WorkProfileContent? _memory;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadMemory();
|
||||
}
|
||||
|
||||
Future<void> _loadMemory() async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final memory = await _memoryService.getWorkMemory();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_memory = memory;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = '加载失败';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onHeaderAction(_WorkMemoryHeaderAction action) {
|
||||
switch (action) {
|
||||
case _WorkMemoryHeaderAction.edit:
|
||||
context.push(AppRoutes.settingsMemoryWorkEdit);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SettingsPageScaffold(
|
||||
title: '工作画像',
|
||||
onBack: () => context.pop(),
|
||||
trailing: DetailHeaderActionMenu<_WorkMemoryHeaderAction>(
|
||||
items: const [
|
||||
DetailHeaderActionItem<_WorkMemoryHeaderAction>(
|
||||
value: _WorkMemoryHeaderAction.edit,
|
||||
label: '编辑',
|
||||
icon: Icons.edit_outlined,
|
||||
),
|
||||
],
|
||||
onSelected: _onHeaderAction,
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_isLoading) ...[
|
||||
const SizedBox(height: AppSpacing.xxl * 2),
|
||||
const Center(child: AppLoadingIndicator(size: 32)),
|
||||
] else if (_error != null) ...[
|
||||
const SizedBox(height: AppSpacing.xxl * 2),
|
||||
_buildErrorState(),
|
||||
] else
|
||||
_buildContent(_memory ?? WorkProfileContent()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 48, color: AppColors.slate300),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(_error ?? '加载失败', style: TextStyle(color: AppColors.slate500)),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
AppPressable(
|
||||
onTap: _loadMemory,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue50,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: Border.all(color: AppColors.blue100),
|
||||
),
|
||||
child: const Text(
|
||||
'重新加载',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.blue600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(WorkProfileContent memory) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildSectionCard(
|
||||
title: '基本信息',
|
||||
icon: Icons.work_outline,
|
||||
children: [
|
||||
_buildInfoRow(Icons.badge_outlined, '职业', _text(memory.occupation)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildSectionCard(
|
||||
title: '专长',
|
||||
icon: Icons.psychology_outlined,
|
||||
children: [_buildTags(memory.expertise)],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildSectionCard(
|
||||
title: '偏好工具',
|
||||
icon: Icons.build_outlined,
|
||||
children: [_buildTags(memory.preferredTools)],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildSectionCard(
|
||||
title: '当前项目',
|
||||
icon: Icons.folder_outlined,
|
||||
children: [_buildProjects(memory.currentProjects)],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildSectionCard(
|
||||
title: '团队成员',
|
||||
icon: Icons.groups_outlined,
|
||||
children: [_buildTeamMembers(memory.teamMembers)],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildSectionCard(
|
||||
title: '工作习惯',
|
||||
icon: Icons.schedule_outlined,
|
||||
children: [
|
||||
_buildInfoRow(
|
||||
Icons.timelapse_outlined,
|
||||
'可用时段',
|
||||
_timeWindowSummary(memory.workHabits.availableHours),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.flash_on_outlined,
|
||||
'深度工作时段',
|
||||
_timeWindowSummary(memory.workHabits.deepWorkBlocks),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.meeting_room_outlined,
|
||||
'偏好会议时段',
|
||||
_timeWindowSummary(memory.workHabits.preferredMeetingWindows),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.do_not_disturb_alt_outlined,
|
||||
'免打扰时段',
|
||||
_timeWindowSummary(memory.workHabits.noMeetingWindows),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.timer_outlined,
|
||||
'偏好会议时长',
|
||||
_intListText(
|
||||
memory.workHabits.preferredMeetingDurationMinutes,
|
||||
suffix: '分钟',
|
||||
),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.notifications_outlined,
|
||||
'通知渠道',
|
||||
_text(memory.workHabits.notificationChannel),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.note_outlined,
|
||||
'备注',
|
||||
_text(memory.workHabits.notes),
|
||||
multiline: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildSectionCard(
|
||||
title: '团队背景',
|
||||
icon: Icons.business_outlined,
|
||||
children: [
|
||||
_buildInfoRow(
|
||||
Icons.apartment_outlined,
|
||||
'团队背景描述',
|
||||
_text(memory.teamContext),
|
||||
multiline: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildSectionCard(
|
||||
title: '工作规则',
|
||||
icon: Icons.rule_outlined,
|
||||
children: [_buildTags(memory.workRules)],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionCard({
|
||||
required String title,
|
||||
required IconData icon,
|
||||
required List<Widget> children,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.violet500.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Icon(icon, size: 16, color: AppColors.violet600),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(
|
||||
IconData icon,
|
||||
String label,
|
||||
String value, {
|
||||
bool multiline = false,
|
||||
}) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceSecondary,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: multiline
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: AppColors.slate500),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProjects(List<CurrentProject> projects) {
|
||||
if (projects.isEmpty) {
|
||||
return _buildEmptyTip('暂无项目');
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: projects.map((project) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceSecondary,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: Border.all(color: AppColors.borderTertiary),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildInfoRow(Icons.title_outlined, '项目名称', _text(project.name)),
|
||||
_buildInfoRow(
|
||||
Icons.subject_outlined,
|
||||
'描述',
|
||||
_text(project.description),
|
||||
multiline: true,
|
||||
),
|
||||
_buildInfoRow(Icons.flag_outlined, '状态', _text(project.status)),
|
||||
_buildInfoRow(
|
||||
Icons.priority_high_outlined,
|
||||
'优先级',
|
||||
_text(project.priority),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.event_outlined,
|
||||
'截止日期',
|
||||
project.deadline == null
|
||||
? '未设置'
|
||||
: project.deadline!.toIso8601String().split('T').first,
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.group_add_outlined,
|
||||
'协作人',
|
||||
_listText(project.collaborators),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.emoji_events_outlined,
|
||||
'关键里程碑',
|
||||
project.keyMilestones.isEmpty
|
||||
? '未设置'
|
||||
: '${project.keyMilestones.length} 项',
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.note_alt_outlined,
|
||||
'备注',
|
||||
_text(project.notes),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTeamMembers(List<TeamMember> members) {
|
||||
if (members.isEmpty) {
|
||||
return _buildEmptyTip('暂无团队成员');
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: members.map((member) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceSecondary,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: Border.all(color: AppColors.borderTertiary),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildInfoRow(Icons.badge_outlined, '姓名', _text(member.name)),
|
||||
_buildInfoRow(
|
||||
Icons.person_pin_outlined,
|
||||
'角色',
|
||||
_text(member.role),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.account_tree_outlined,
|
||||
'关系',
|
||||
_text(member.relationship),
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.phone_outlined,
|
||||
'联系方式',
|
||||
_text(member.preferredContactChannel),
|
||||
),
|
||||
_buildInfoRow(Icons.note_outlined, '备注', _text(member.notes)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTags(List<String> tags) {
|
||||
if (tags.isEmpty) {
|
||||
return _buildEmptyTip('暂无数据');
|
||||
}
|
||||
return Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: tags.map((tag) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.violet500.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(
|
||||
color: AppColors.violet500.withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.label_outline, size: 14, color: AppColors.violet500),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Text(
|
||||
tag,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.violet600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyTip(String text) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceSecondary,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: Border.all(color: AppColors.borderTertiary),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.inbox_outlined, size: 16, color: AppColors.slate400),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _text(String? value) {
|
||||
final raw = value?.trim() ?? '';
|
||||
return raw.isEmpty ? '未设置' : raw;
|
||||
}
|
||||
|
||||
String _listText(List<String> values) {
|
||||
if (values.isEmpty) return '未设置';
|
||||
return values.join('、');
|
||||
}
|
||||
|
||||
String _intListText(List<int> values, {required String suffix}) {
|
||||
if (values.isEmpty) return '未设置';
|
||||
return values.map((value) => '$value$suffix').join('、');
|
||||
}
|
||||
|
||||
String _timeWindowSummary(List<TimeWindow> windows) {
|
||||
if (windows.isEmpty) return '未设置';
|
||||
return '${windows.length} 个时段';
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ class SettingsPageScaffold extends StatelessWidget {
|
||||
required this.body,
|
||||
this.footer,
|
||||
this.onBack,
|
||||
this.trailing,
|
||||
this.resizeOnKeyboard = true,
|
||||
this.maintainBottomViewPadding = false,
|
||||
});
|
||||
@@ -18,6 +19,7 @@ class SettingsPageScaffold extends StatelessWidget {
|
||||
final Widget body;
|
||||
final Widget? footer;
|
||||
final VoidCallback? onBack;
|
||||
final Widget? trailing;
|
||||
final bool resizeOnKeyboard;
|
||||
final bool maintainBottomViewPadding;
|
||||
|
||||
@@ -31,7 +33,11 @@ class SettingsPageScaffold extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
BackTitlePageHeader(title: title, onBack: onBack),
|
||||
BackTitlePageHeader(
|
||||
title: title,
|
||||
onBack: onBack,
|
||||
trailing: trailing,
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: social_app
|
||||
description: "Social App - A Flutter mobile application"
|
||||
publish_to: 'none'
|
||||
version: 0.1.0+3
|
||||
version: 0.1.1+4
|
||||
environment:
|
||||
sdk: ^3.10.7
|
||||
|
||||
|
||||
Reference in New Issue
Block a user