feat(apps): 重构 UI 架构为 presentation 层并新增 l10n 国际化支持

This commit is contained in:
qzl
2026-03-27 14:05:03 +08:00
parent b1f0eb8921
commit c592cc7854
178 changed files with 10748 additions and 5764 deletions
@@ -0,0 +1,428 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:social_app/core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../app/di/injection.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/services/settings_user_cache.dart';
import '../../../contacts/data/users/models/user_response.dart';
import '../../../contacts/data/users/users_api.dart';
import '../widgets/account_section_card.dart';
import '../widgets/settings_page_scaffold.dart';
class EditProfileScreen extends StatefulWidget {
const EditProfileScreen({super.key});
@override
State<EditProfileScreen> createState() => _EditProfileScreenState();
}
class _EditProfileScreenState extends State<EditProfileScreen> {
final _usernameController = TextEditingController();
final _bioController = TextEditingController();
final _usersApi = sl<UsersApi>();
final _userCache = sl<SettingsUserCache>();
final _imagePicker = ImagePicker();
UserResponse? _user;
File? _selectedAvatar;
bool _isLoading = true;
bool _isSaving = false;
bool _isUploadingAvatar = false;
bool _hasChanges = false;
@override
void initState() {
super.initState();
_loadUser();
}
Future<void> _loadUser() async {
final cached = _userCache.cachedUser;
if (cached != null) {
setState(() {
_user = cached;
_usernameController.text = cached.username;
_bioController.text = cached.bio ?? '';
_isLoading = false;
});
return;
}
try {
final user = await _usersApi.getMe();
if (mounted) {
_userCache.set(user);
setState(() {
_user = user;
_usernameController.text = user.username;
_bioController.text = user.bio ?? '';
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
Toast.show(
context,
context.l10n.settingsEditProfileLoadFailed,
type: ToastType.error,
);
}
}
}
void _onFieldChanged() {
if (_user == null) return;
final usernameChanged = _usernameController.text != _user!.username;
final bioChanged = _bioController.text != (_user!.bio ?? '');
final avatarChanged = _selectedAvatar != null;
if ((usernameChanged || bioChanged || avatarChanged) != _hasChanges) {
setState(() {
_hasChanges = usernameChanged || bioChanged || avatarChanged;
});
}
}
Future<void> _pickAvatar() async {
final pickedFile = await _imagePicker.pickImage(
source: ImageSource.gallery,
maxWidth: 400,
maxHeight: 400,
imageQuality: 80,
);
if (pickedFile != null) {
setState(() {
_selectedAvatar = File(pickedFile.path);
_hasChanges = true;
});
}
}
Future<void> _uploadAvatar() async {
if (_selectedAvatar == null) return;
setState(() {
_isUploadingAvatar = true;
});
try {
await _usersApi.uploadAvatar(_selectedAvatar!);
if (mounted) {
Toast.show(
context,
context.l10n.settingsEditProfileAvatarUploadSuccess,
type: ToastType.success,
);
_selectedAvatar = null;
await _loadUser();
}
} catch (e) {
if (mounted) {
Toast.show(
context,
context.l10n.settingsEditProfileAvatarUploadFailed,
type: ToastType.error,
);
}
} finally {
if (mounted) {
setState(() {
_isUploadingAvatar = false;
});
}
}
}
Future<void> _saveProfile() async {
if (!_hasChanges || _user == null) return;
final newUsername = _usernameController.text.trim();
final newBio = _bioController.text.trim();
final usernameChanged = newUsername != _user!.username;
final bioChanged = newBio != (_user!.bio ?? '');
if (usernameChanged) {
if (newUsername.isEmpty) {
Toast.show(
context,
context.l10n.settingsEditProfileUsernameRequired,
type: ToastType.warning,
);
return;
}
if (newUsername.length < 3 || newUsername.length > 30) {
Toast.show(
context,
context.l10n.settingsEditProfileUsernameLengthInvalid,
type: ToastType.warning,
);
return;
}
}
setState(() {
_isSaving = true;
});
try {
if (_selectedAvatar != null) {
await _uploadAvatar();
}
if (usernameChanged || bioChanged) {
final request = UserUpdateRequest(
username: usernameChanged ? newUsername : null,
bio: bioChanged ? (newBio.isEmpty ? null : newBio) : null,
);
final updatedUser = await _usersApi.updateMe(request);
_userCache.set(updatedUser);
}
if (mounted) {
Toast.show(
context,
context.l10n.settingsEditProfileSaveSuccess,
type: ToastType.success,
);
context.pop(true);
}
} catch (e) {
if (mounted) {
Toast.show(
context,
context.l10n.settingsEditProfileSaveFailed,
type: ToastType.error,
);
}
} finally {
if (mounted) {
setState(() {
_isSaving = false;
});
}
}
}
@override
void dispose() {
_usernameController.dispose();
_bioController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return SettingsPageScaffold(
title: l10n.settingsEditProfileTitle,
onBack: () => context.pop(),
resizeOnKeyboard: false,
maintainBottomViewPadding: true,
body: _isLoading
? const Center(
child: AppLoadingIndicator(variant: AppLoadingVariant.surface),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildBasicInfoSection(),
const SizedBox(height: AppSpacing.lg),
_buildBioSection(),
],
),
footer: SizedBox(
width: double.infinity,
height: 52,
child: AppButton(
text: l10n.settingsEditProfileSaveChanges,
onPressed: _hasChanges && !_isSaving ? _saveProfile : null,
isLoading: _isSaving,
),
),
);
}
Widget _buildBasicInfoSection() {
final l10n = context.l10n;
return AccountSectionCard(
title: l10n.settingsEditProfileBasicInfo,
backgroundColor: AppColors.white,
borderColor: AppColors.borderSecondary,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAvatarSection(),
const SizedBox(height: AppSpacing.lg),
Text(
l10n.settingsEditProfileUsername,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.slate700,
),
),
const SizedBox(height: AppSpacing.sm),
TextField(
controller: _usernameController,
onChanged: (_) => _onFieldChanged(),
style: const TextStyle(fontSize: 15, color: AppColors.slate900),
decoration: _buildInputDecoration(
l10n.settingsEditProfileUsernameHint,
),
),
],
),
);
}
Widget _buildAvatarSection() {
final avatarUrl = _user?.avatarUrl;
final hasSelectedAvatar = _selectedAvatar != null;
return Center(
child: GestureDetector(
onTap: _isUploadingAvatar ? null : _pickAvatar,
child: Stack(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.surfaceSecondary,
border: Border.all(color: AppColors.borderTertiary, width: 2),
image: hasSelectedAvatar
? DecorationImage(
image: FileImage(_selectedAvatar!),
fit: BoxFit.cover,
)
: avatarUrl != null
? DecorationImage(
image: NetworkImage(avatarUrl),
fit: BoxFit.cover,
)
: null,
),
child: !hasSelectedAvatar && avatarUrl == null
? const Icon(
Icons.person,
size: 40,
color: AppColors.slate400,
)
: null,
),
if (_isUploadingAvatar)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.black.withValues(alpha: 0.4),
),
child: const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
AppColors.white,
),
),
),
),
),
),
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.blue500,
border: Border.all(color: AppColors.white, width: 2),
),
child: const Icon(
Icons.camera_alt,
size: 14,
color: AppColors.white,
),
),
),
],
),
),
);
}
Widget _buildBioSection() {
final l10n = context.l10n;
return AccountSectionCard(
title: l10n.settingsEditProfileBio,
backgroundColor: AppColors.white,
borderColor: AppColors.borderSecondary,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.settingsEditProfileBioContent,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.slate700,
),
),
const SizedBox(height: AppSpacing.sm),
TextField(
controller: _bioController,
onChanged: (_) => _onFieldChanged(),
maxLines: 4,
maxLength: 200,
style: const TextStyle(fontSize: 15, color: AppColors.slate900),
decoration: _buildInputDecoration(
l10n.settingsEditProfileBioHint,
).copyWith(contentPadding: const EdgeInsets.all(AppSpacing.lg)),
),
],
),
);
}
InputDecoration _buildInputDecoration(String hintText) {
return InputDecoration(
hintText: hintText,
hintStyle: const TextStyle(fontSize: 14, color: AppColors.slate400),
filled: true,
fillColor: AppColors.surfaceSecondary,
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.lg,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
borderSide: const BorderSide(color: AppColors.borderTertiary),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
borderSide: const BorderSide(color: AppColors.blue500),
),
);
}
}
@@ -0,0 +1,223 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../../app/di/injection.dart';
import '../../../../app/router/app_routes.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/app_pressable.dart';
import '../../../../shared/widgets/app_toggle_switch.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/models/automation_job_model.dart';
import '../../data/services/automation_jobs_api.dart';
import '../../presentation/cubits/automation_jobs_cubit.dart';
import '../widgets/settings_page_scaffold.dart';
class FeaturesScreen extends StatefulWidget {
const FeaturesScreen({super.key});
@override
State<FeaturesScreen> createState() => _FeaturesScreenState();
}
class _FeaturesScreenState extends State<FeaturesScreen> {
late final AutomationJobsCubit _cubit;
@override
void initState() {
super.initState();
_cubit = AutomationJobsCubit(sl<AutomationJobsApi>());
_cubit.loadJobs();
}
@override
void dispose() {
_cubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cubit,
child: SettingsPageScaffold(
title: context.l10n.settingsFeaturesTitle,
onBack: () => context.pop(),
body: BlocBuilder<AutomationJobsCubit, AutomationJobsState>(
builder: (context, state) {
if (state.isLoading) {
return const Center(child: AppLoadingIndicator());
}
if (state.error != null) {
return Center(child: Text(state.error!));
}
return _buildJobList(state);
},
),
),
);
}
Widget _buildJobList(AutomationJobsState state) {
final dailyJobs = state.dailyJobs;
final weeklyJobs = state.weeklyJobs;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle(context.l10n.settingsSectionDaily),
const SizedBox(height: AppSpacing.sm),
if (dailyJobs.isEmpty)
_buildEmptyHint(context.l10n.settingsNoDailyPlans)
else
...dailyJobs.map(_buildJobCard),
const SizedBox(height: AppSpacing.lg),
_buildSectionTitle(context.l10n.settingsSectionWeekly),
const SizedBox(height: AppSpacing.sm),
if (weeklyJobs.isEmpty)
_buildEmptyHint(context.l10n.settingsNoWeeklyPlans)
else
...weeklyJobs.map(_buildJobCard),
if (state.canCreateMore) ...[
const SizedBox(height: AppSpacing.lg),
_buildCreateButton(),
],
],
);
}
Widget _buildEmptyHint(String text) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSecondary),
),
child: Text(
text,
style: const TextStyle(
color: AppColors.slate500,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.slate500,
),
);
}
Widget _buildJobCard(AutomationJobModel job) {
return AppPressable(
onTap: () async {
await context.push(AppRoutes.settingsJobDetail(job.id));
if (!mounted) {
return;
}
_cubit.loadJobs();
},
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Container(
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSecondary),
),
child: Row(
children: [
Container(
width: AppSpacing.xxl + AppSpacing.lg,
height: AppSpacing.xxl + AppSpacing.lg,
decoration: BoxDecoration(
color: AppColors.surfaceTertiary,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: const Icon(
Icons.auto_awesome,
size: AppSpacing.lg,
color: AppColors.blue500,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
job.title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
_buildSubtitle(job),
style: const TextStyle(
fontSize: 12,
color: AppColors.slate500,
),
),
],
),
),
const SizedBox(width: AppSpacing.sm),
AppToggleSwitch(
value: job.isActive,
onChanged: (next) {
if (job.isSystem) {
Toast.show(
context,
context.l10n.settingsSystemJobReadonly,
type: ToastType.info,
);
return;
}
_cubit.updateJobStatus(id: job.id, enabled: next);
},
),
],
),
),
);
}
String _buildSubtitle(AutomationJobModel job) {
final statusText = job.isActive
? context.l10n.settingsJobStatusEnabled
: context.l10n.settingsJobStatusDisabled;
final sourceText = job.isSystem
? context.l10n.settingsJobSourceSystem
: context.l10n.settingsJobSourceCustom;
return '$sourceText$statusText';
}
Widget _buildCreateButton() {
return AppButton(
text: context.l10n.settingsCreateJob,
onPressed: () async {
await context.push(AppRoutes.settingsJobNew);
if (!mounted) {
return;
}
_cubit.loadJobs();
},
);
}
}
@@ -0,0 +1,946 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:social_app/core/l10n/l10n.dart';
import '../../../../app/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_input.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/app_pressable.dart';
import '../../../../shared/widgets/app_selection_sheet.dart';
import '../../../../shared/widgets/detail_header_action_menu.dart';
import '../../../../shared/widgets/destructive_action_sheet.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../../core/utils/tool_name_localizer.dart';
import '../../data/models/automation_job_model.dart';
import '../../data/services/automation_jobs_api.dart';
import '../../presentation/cubits/job_detail_cubit.dart';
import '../widgets/settings_page_scaffold.dart';
class JobDetailScreen extends StatefulWidget {
const JobDetailScreen({super.key, this.jobId});
final String? jobId;
@override
State<JobDetailScreen> createState() => _JobDetailScreenState();
}
enum _JobDetailHeaderAction { delete }
class _JobDetailScreenState extends State<JobDetailScreen> {
late final JobDetailCubit _cubit;
final TextEditingController _titleController = TextEditingController();
final TextEditingController _templateController = TextEditingController();
String _scheduleType = 'daily';
String _timezone = 'Asia/Shanghai';
TimeOfDay _runAt = const TimeOfDay(hour: 8, minute: 0);
final Set<int> _selectedWeekdays = <int>{1};
String _contextSource = 'latest_chat';
String _contextWindowMode = 'day';
int _contextWindowCount = 2;
final Set<String> _selectedTools = <String>{'memory.write', 'memory.forget'};
@override
void initState() {
super.initState();
_cubit = JobDetailCubit(sl<AutomationJobsApi>());
if (widget.jobId != null) {
_cubit.loadJob(widget.jobId!);
}
}
@override
void dispose() {
_titleController.dispose();
_templateController.dispose();
_cubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cubit,
child: BlocConsumer<JobDetailCubit, JobDetailState>(
listener: (context, state) {
if (state.error != null) {
Toast.show(context, state.error!, type: ToastType.error);
}
},
builder: (context, state) {
final l10n = context.l10n;
if (state.isLoading) {
return SettingsPageScaffold(
title: l10n.commonLoading,
body: const Center(child: AppLoadingIndicator()),
);
}
final job = state.job;
final isEditMode = widget.jobId != null;
if (isEditMode && job == null && state.error != null) {
return SettingsPageScaffold(
title: l10n.settingsJobDetailTitle,
onBack: () => context.pop(),
body: _buildLoadFailedView(state.error!),
);
}
return SettingsPageScaffold(
title: job?.title ?? l10n.settingsJobCreatePageTitle,
onBack: () => context.pop(),
trailing: job != null && !job.isSystem
? _buildHeaderActions(job.id, state)
: null,
body: job == null
? _buildCreateForm(state)
: _buildDetailPage(job, state),
);
},
),
);
}
Widget _buildLoadFailedView(String error) {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle(l10n.settingsJobLoadFailed),
const SizedBox(height: AppSpacing.sm),
Text(
error,
style: const TextStyle(
color: AppColors.error,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: AppSpacing.md),
AppButton(
text: l10n.settingsJobRetry,
onPressed: () => _cubit.loadJob(widget.jobId!),
),
],
);
}
Widget _buildDetailPage(AutomationJobModel job, JobDetailState state) {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildOverviewCard(job),
const SizedBox(height: AppSpacing.lg),
_buildSectionTitle(l10n.settingsJobPlanConfig),
const SizedBox(height: AppSpacing.sm),
_buildInfoCard([
_buildInfoRow(
l10n.settingsJobCycle,
_scheduleLabel(job.config.schedule.type),
),
_buildInfoRow(
l10n.settingsJobRunAt,
_displayRunAt(job.config.schedule),
),
_buildInfoRow(l10n.settingsJobTimezone, job.timezone),
_buildInfoRow(
l10n.settingsJobStatusLabel,
job.isActive
? l10n.settingsJobStatusEnabled
: l10n.settingsJobStatusDisabled,
),
]),
const SizedBox(height: AppSpacing.lg),
_buildSectionTitle(l10n.settingsJobInputTemplate),
const SizedBox(height: AppSpacing.sm),
_buildTextBlock(job.config.inputTemplate),
const SizedBox(height: AppSpacing.lg),
_buildSectionTitle(l10n.settingsJobEnabledTools),
const SizedBox(height: AppSpacing.sm),
_buildToolWrap(job.config.enabledTools),
const SizedBox(height: AppSpacing.lg),
_buildSectionTitle(l10n.settingsJobContextMode),
const SizedBox(height: AppSpacing.sm),
_buildInfoCard([
_buildInfoRow(
l10n.settingsJobContextSource,
_contextSourceLabel(job.config.context.source),
),
_buildInfoRow(
l10n.settingsJobWindowMode,
_windowModeLabel(job.config.context.windowMode),
),
_buildInfoRow(
l10n.settingsJobWindowCount,
l10n.settingsJobWindowCountValue(job.config.context.windowCount),
),
]),
if (!job.isSystem && state.isSaving)
const Padding(
padding: EdgeInsets.only(top: AppSpacing.lg),
child: Center(child: AppLoadingIndicator(size: AppSpacing.lg)),
),
],
);
}
Widget _buildHeaderActions(String jobId, JobDetailState state) {
final l10n = context.l10n;
return DetailHeaderActionMenu<_JobDetailHeaderAction>(
items: [
DetailHeaderActionItem<_JobDetailHeaderAction>(
value: _JobDetailHeaderAction.delete,
label: l10n.commonDelete,
icon: Icons.delete_outline,
isDestructive: true,
),
],
onSelected: (action) {
if (state.isSaving) {
return;
}
if (action == _JobDetailHeaderAction.delete) {
unawaited(_confirmAndDelete(jobId));
}
},
);
}
Future<void> _confirmAndDelete(String jobId) async {
final l10n = context.l10n;
final confirmed = await showDestructiveActionSheet(
context,
title: l10n.settingsJobDeleteTitle,
message: l10n.settingsJobDeleteMessage,
confirmText: l10n.settingsJobDeleteConfirm,
);
if (!confirmed) {
return;
}
final success = await _cubit.deleteJob(jobId);
if (!mounted) {
return;
}
if (success) {
Toast.show(
context,
l10n.settingsJobDeleteSuccess,
type: ToastType.success,
);
context.pop();
}
}
Widget _buildOverviewCard(AutomationJobModel job) {
final l10n = context.l10n;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.white, AppColors.surfaceInfoLight],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderTertiary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
job.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
),
const SizedBox(height: AppSpacing.sm),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
_buildBadge(
job.isSystem
? l10n.settingsJobSourceSystem
: l10n.settingsJobSourceCustom,
),
_buildBadge(
job.isActive
? l10n.settingsJobStatusEnabled
: l10n.settingsJobStatusDisabled,
),
_buildBadge(_scheduleLabel(job.config.schedule.type)),
],
),
],
),
);
}
Widget _buildBadge(String text) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: AppColors.borderSecondary),
),
child: Text(
text,
style: const TextStyle(
color: AppColors.slate600,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
Widget _buildCreateForm(JobDetailState state) {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCreateBasicSection(),
const SizedBox(height: AppSpacing.lg),
_buildCreateRuleSection(),
const SizedBox(height: AppSpacing.lg),
_buildCreateToolSection(),
const SizedBox(height: AppSpacing.lg),
_buildCreateContextSection(),
const SizedBox(height: AppSpacing.xl),
AppButton(
text: l10n.settingsCreateJob,
isLoading: state.isSaving,
onPressed: state.isSaving ? null : _submitCreate,
),
],
);
}
Widget _buildCreateBasicSection() {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle(l10n.settingsJobBasicInfo),
const SizedBox(height: AppSpacing.sm),
AppInput(
label: l10n.settingsJobName,
hint: l10n.settingsJobNameHint,
controller: _titleController,
),
const SizedBox(height: AppSpacing.md),
AppInput(
label: l10n.settingsJobInputTemplate,
hint: l10n.settingsJobTemplateHint,
controller: _templateController,
maxLines: 4,
),
],
);
}
Widget _buildCreateRuleSection() {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle(l10n.settingsJobExecutionRules),
const SizedBox(height: AppSpacing.sm),
_buildPickerTile(
label: l10n.settingsJobCycle,
value: _scheduleLabel(_scheduleType),
onTap: _pickScheduleType,
),
if (_scheduleType == 'weekly') ...[
const SizedBox(height: AppSpacing.sm),
_buildWeekdaySelector(),
],
const SizedBox(height: AppSpacing.sm),
_buildPickerTile(
label: l10n.settingsJobRunAt,
value: _formatTime(_runAt),
onTap: _pickRunAt,
),
const SizedBox(height: AppSpacing.sm),
_buildPickerTile(
label: l10n.settingsJobTimezone,
value: _timezone,
onTap: _pickTimezone,
),
],
);
}
Widget _buildCreateToolSection() {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle(l10n.settingsJobToolSelection),
const SizedBox(height: AppSpacing.sm),
_buildToolSelector(),
],
);
}
Widget _buildCreateContextSection() {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle(l10n.settingsJobContextMode),
const SizedBox(height: AppSpacing.sm),
_buildPickerTile(
label: l10n.settingsJobContextSource,
value: _contextSourceLabel(_contextSource),
onTap: _pickContextSource,
),
const SizedBox(height: AppSpacing.sm),
_buildPickerTile(
label: l10n.settingsJobWindowMode,
value: _windowModeLabel(_contextWindowMode),
onTap: _pickWindowMode,
),
const SizedBox(height: AppSpacing.sm),
_buildCounterTile(
label: l10n.settingsJobWindowCount,
value: _contextWindowCount,
onMinus: _contextWindowCount > 1
? () {
setState(() {
_contextWindowCount -= 1;
});
}
: null,
onPlus: _contextWindowCount < 200
? () {
setState(() {
_contextWindowCount += 1;
});
}
: null,
),
],
);
}
Widget _buildPickerTile({
required String label,
required String value,
required VoidCallback onTap,
}) {
return AppPressable(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.md,
),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSecondary),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
color: AppColors.slate500,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
value,
style: const TextStyle(
color: AppColors.slate800,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
),
),
const Icon(Icons.keyboard_arrow_down, color: AppColors.slate400),
],
),
),
);
}
Widget _buildCounterTile({
required String label,
required int value,
required VoidCallback? onMinus,
required VoidCallback? onPlus,
}) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.md,
),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSecondary),
),
child: Row(
children: [
Expanded(
child: Text(
context.l10n.settingsJobCounterValue(label, value),
style: const TextStyle(
color: AppColors.slate800,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
_buildCounterAction(icon: Icons.remove, onTap: onMinus),
const SizedBox(width: AppSpacing.sm),
_buildCounterAction(icon: Icons.add, onTap: onPlus),
],
),
);
}
Widget _buildCounterAction({
required IconData icon,
required VoidCallback? onTap,
}) {
return AppPressable(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.full),
child: Container(
width: AppSpacing.xxl + AppSpacing.md,
height: AppSpacing.xxl + AppSpacing.md,
decoration: BoxDecoration(
color: onTap == null ? AppColors.slate100 : AppColors.surfaceTertiary,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: AppColors.borderSecondary),
),
child: Icon(
icon,
size: AppSpacing.lg,
color: onTap == null ? AppColors.slate300 : AppColors.blue500,
),
),
);
}
Widget _buildToolSelector() {
return Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: automationToolOptions.map((toolName) {
final selected = _selectedTools.contains(toolName);
return AppPressable(
onTap: () {
setState(() {
if (selected) {
_selectedTools.remove(toolName);
} else {
_selectedTools.add(toolName);
}
});
},
borderRadius: BorderRadius.circular(AppRadius.full),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: selected ? AppColors.blue50 : AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(
color: selected ? AppColors.blue300 : AppColors.borderSecondary,
),
),
child: Text(
localizeToolName(toolName),
style: TextStyle(
color: selected ? AppColors.blue600 : AppColors.slate600,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
);
}).toList(),
);
}
Widget _buildWeekdaySelector() {
final l10n = context.l10n;
final weekdayLabels = <int, String>{
1: l10n.settingsJobWeekdayMon,
2: l10n.settingsJobWeekdayTue,
3: l10n.settingsJobWeekdayWed,
4: l10n.settingsJobWeekdayThu,
5: l10n.settingsJobWeekdayFri,
6: l10n.settingsJobWeekdaySat,
7: l10n.settingsJobWeekdaySun,
};
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSecondary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.settingsJobRunDays,
style: TextStyle(
color: AppColors.slate500,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: AppSpacing.sm),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: weekdayLabels.entries.map((entry) {
final selected = _selectedWeekdays.contains(entry.key);
return AppPressable(
onTap: () {
setState(() {
if (selected) {
if (_selectedWeekdays.length > 1) {
_selectedWeekdays.remove(entry.key);
}
} else {
_selectedWeekdays.add(entry.key);
}
});
},
borderRadius: BorderRadius.circular(AppRadius.full),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: selected ? AppColors.blue50 : AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(
color: selected
? AppColors.blue300
: AppColors.borderSecondary,
),
),
child: Text(
entry.value,
style: TextStyle(
color: selected ? AppColors.blue600 : AppColors.slate600,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
);
}).toList(),
),
],
),
);
}
Widget _buildToolWrap(List<String> tools) {
if (tools.isEmpty) {
return _buildTextBlock(context.l10n.settingsJobNoToolsEnabled);
}
return Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: tools
.map((tool) => _buildBadge(localizeToolName(tool)))
.toList(),
);
}
Widget _buildTextBlock(String text) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSecondary),
),
child: Text(
text,
style: const TextStyle(
color: AppColors.slate700,
fontSize: 13,
fontWeight: FontWeight.w500,
height: 1.5,
),
),
);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.slate500,
),
);
}
Widget _buildInfoCard(List<Widget> children) {
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSecondary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: const TextStyle(
color: AppColors.slate500,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Text(
value,
textAlign: TextAlign.right,
style: const TextStyle(
color: AppColors.slate800,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
Future<void> _pickScheduleType() async {
final l10n = context.l10n;
final picked = await showAppSelectionSheet<String>(
context,
title: l10n.settingsJobPickCycle,
selectedValue: _scheduleType,
items: [
AppSelectionItem(value: 'daily', label: l10n.settingsJobScheduleDaily),
AppSelectionItem(
value: 'weekly',
label: l10n.settingsJobScheduleWeekly,
),
],
);
if (picked != null) {
setState(() {
_scheduleType = picked;
if (_scheduleType == 'weekly' && _selectedWeekdays.isEmpty) {
_selectedWeekdays.add(1);
}
});
}
}
Future<void> _pickTimezone() async {
final picked = await showAppSelectionSheet<String>(
context,
title: context.l10n.settingsJobPickTimezone,
selectedValue: _timezone,
items: const [
AppSelectionItem(value: 'Asia/Shanghai', label: 'Asia/Shanghai'),
AppSelectionItem(value: 'UTC', label: 'UTC'),
],
);
if (picked != null) {
setState(() {
_timezone = picked;
});
}
}
Future<void> _pickContextSource() async {
final l10n = context.l10n;
final picked = await showAppSelectionSheet<String>(
context,
title: l10n.settingsJobPickContextSource,
selectedValue: _contextSource,
items: [
AppSelectionItem(
value: 'latest_chat',
label: l10n.settingsJobContextSourceLatestChat,
),
],
);
if (picked != null) {
setState(() {
_contextSource = picked;
});
}
}
Future<void> _pickWindowMode() async {
final l10n = context.l10n;
final picked = await showAppSelectionSheet<String>(
context,
title: l10n.settingsJobPickWindowMode,
selectedValue: _contextWindowMode,
items: [
AppSelectionItem(value: 'day', label: l10n.settingsJobWindowModeByDay),
AppSelectionItem(
value: 'number',
label: l10n.settingsJobWindowModeByNumber,
),
],
);
if (picked != null) {
setState(() {
_contextWindowMode = picked;
});
}
}
Future<void> _pickRunAt() async {
final picked = await showTimePicker(context: context, initialTime: _runAt);
if (picked != null) {
setState(() {
_runAt = picked;
});
}
}
String _formatTime(TimeOfDay time) {
final hour = time.hour.toString().padLeft(2, '0');
final minute = time.minute.toString().padLeft(2, '0');
return '$hour:$minute:00';
}
String _displayRunAt(ScheduleConfigModel schedule) {
final hour = schedule.runAt.hour.toString().padLeft(2, '0');
final minute = schedule.runAt.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
String _scheduleLabel(String scheduleType) {
final l10n = context.l10n;
final normalized = scheduleType.toLowerCase();
if (normalized == 'daily') {
return l10n.settingsJobScheduleDaily;
}
if (normalized == 'weekly') {
return l10n.settingsJobScheduleWeekly;
}
return scheduleType;
}
String _contextSourceLabel(String source) {
if (source == 'latest_chat') {
return context.l10n.settingsJobContextSourceLatestChat;
}
return source;
}
String _windowModeLabel(String mode) {
if (mode == 'day') {
return context.l10n.settingsJobWindowModeByDay;
}
if (mode == 'number') {
return context.l10n.settingsJobWindowModeByNumber;
}
return mode;
}
Future<void> _submitCreate() async {
final title = _titleController.text.trim();
final template = _templateController.text.trim();
if (title.isEmpty || template.isEmpty) {
Toast.show(
context,
context.l10n.settingsJobFillRequired,
type: ToastType.error,
);
return;
}
final request = AutomationJobCreateRequest(
title: title,
timezone: _timezone,
status: 'active',
config: AutomationJobConfigModel(
inputTemplate: template,
enabledTools: _selectedTools.toList(),
context: MessageContextConfigModel(
source: _contextSource,
windowMode: _contextWindowMode,
windowCount: _contextWindowCount,
),
schedule: ScheduleConfigModel(
type: _scheduleType,
runAt: ScheduleRunAtModel(hour: _runAt.hour, minute: _runAt.minute),
weekdays: _scheduleType == 'weekly'
? (_selectedWeekdays.toList()..sort())
: null,
),
),
);
final success = await _cubit.createJob(request);
if (!mounted) {
return;
}
if (success) {
Toast.show(
context,
context.l10n.settingsJobCreateSuccess,
type: ToastType.success,
);
context.pop(true);
}
}
}
@@ -0,0 +1,501 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:social_app/app/di/injection.dart';
import 'package:social_app/core/theme/design_tokens.dart';
import 'package:social_app/core/l10n/l10n.dart';
import 'package:social_app/app/router/app_routes.dart';
import 'package:social_app/shared/widgets/app_loading_indicator.dart';
import 'package:social_app/shared/widgets/app_pressable.dart';
import '../widgets/settings_page_scaffold.dart';
import '../../data/models/memory_models.dart';
import '../../data/services/memory_service.dart';
class MemoryScreen extends StatefulWidget {
const MemoryScreen({super.key});
@override
State<MemoryScreen> createState() => _MemoryScreenState();
}
class _MemoryScreenState extends State<MemoryScreen> {
final MemoryService _memoryService = sl<MemoryService>();
MemoryListResponse? _memoryData;
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_loadMemories();
}
Future<void> _loadMemories() async {
if (!mounted) return;
setState(() {
_isLoading = true;
_error = null;
});
try {
final data = await _memoryService.getAllMemories();
if (!mounted) return;
setState(() {
_memoryData = data;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = L10n.current.memoryLoadFailedRetry;
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return SettingsPageScaffold(
title: context.l10n.memoryTitle,
onBack: () => context.pop(),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildToggleCard(),
const SizedBox(height: AppSpacing.lg),
if (_isLoading) ...[
const SizedBox(height: AppSpacing.xxl),
_buildLoadingState(),
] else if (_error != null) ...[
const SizedBox(height: AppSpacing.xxl),
_buildErrorState(),
] else ...[
const SizedBox(height: AppSpacing.sm),
_buildMemoryCards(),
],
],
),
);
}
Widget _buildToggleCard() {
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.white, AppColors.surfaceInfoLight],
),
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderTertiary),
boxShadow: [
BoxShadow(
color: AppColors.blue100.withValues(alpha: 0.35),
blurRadius: 14,
offset: const Offset(0, 4),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.blue100, AppColors.blue50],
),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppColors.blue200.withValues(alpha: 0.45),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: const Icon(
Icons.auto_awesome,
size: 22,
color: AppColors.blue600,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.memorySmartTitle,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
const SizedBox(height: 2),
Text(
context.l10n.memorySmartDesc,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
],
),
),
],
),
);
}
Widget _buildLoadingState() {
return Center(
child: Container(
padding: const EdgeInsets.all(AppSpacing.xxl),
child: const AppLoadingIndicator(size: 32),
),
);
}
Widget _buildErrorState() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: AppColors.slate300),
const SizedBox(height: AppSpacing.md),
Text(
_error ?? context.l10n.memoryLoadFailedRetry,
style: TextStyle(fontSize: 14, color: AppColors.slate500),
),
const SizedBox(height: AppSpacing.lg),
AppPressable(
onTap: _loadMemories,
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: Text(
context.l10n.memoryReload,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.blue600,
),
),
),
),
],
),
);
}
Widget _buildMemoryCards() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSectionLabel(context.l10n.memorySectionUser),
const SizedBox(height: AppSpacing.sm),
_buildUserMemoryCard(),
const SizedBox(height: AppSpacing.md),
_buildSectionLabel(context.l10n.memorySectionWork),
const SizedBox(height: AppSpacing.sm),
_buildWorkMemoryCard(),
],
);
}
Widget _buildSectionLabel(String label) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.slate500,
),
),
);
}
Widget _buildUserMemoryCard() {
final userMemory = _memoryData?.userMemory;
final hasData = userMemory != null;
return AppPressable(
onTap: () => context.push(AppRoutes.settingsMemoryUser),
borderRadius: BorderRadius.circular(AppRadius.xl),
child: Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.white, AppColors.surfaceInfoLight],
),
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderTertiary),
boxShadow: [
BoxShadow(
color: AppColors.slate200.withValues(alpha: 0.45),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.blue100.withValues(alpha: 0.8),
AppColors.blue50.withValues(alpha: 0.8),
],
),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.person,
size: 20,
color: AppColors.blue600,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.memoryUserProfile,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
const SizedBox(height: 2),
Text(
hasData
? userMemory.summary
: context.l10n.memoryNoInfo,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Icon(Icons.chevron_right, size: 20, color: AppColors.slate400),
],
),
if (hasData) ...[
const SizedBox(height: AppSpacing.md),
_buildMemoryStatsRow(
icons: [
Icons.people_outline,
Icons.place_outlined,
Icons.interests_outlined,
Icons.schedule_outlined,
],
values: [
'${userMemory.people.length}',
'${userMemory.places.length}',
'${userMemory.interests.length}',
'${userMemory.recurringRoutines.length}',
],
labels: [
context.l10n.memoryStatContacts,
context.l10n.memoryStatPlaces,
context.l10n.memoryStatInterests,
context.l10n.memoryStatSchedule,
],
),
],
],
),
),
);
}
Widget _buildWorkMemoryCard() {
final workMemory = _memoryData?.workMemory;
final hasData = workMemory != null;
return AppPressable(
onTap: () => context.push(AppRoutes.settingsMemoryWork),
borderRadius: BorderRadius.circular(AppRadius.xl),
child: Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.white, AppColors.surfaceTertiary],
),
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderSecondary),
boxShadow: [
BoxShadow(
color: AppColors.slate200.withValues(alpha: 0.4),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.violet500.withValues(alpha: 0.15),
AppColors.violet500.withValues(alpha: 0.05),
],
),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.work_outline,
size: 20,
color: AppColors.violet600,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.memoryWorkProfile,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
const SizedBox(height: 2),
Text(
hasData
? workMemory.summary
: context.l10n.memoryNoInfo,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Icon(Icons.chevron_right, size: 20, color: AppColors.slate400),
],
),
if (hasData) ...[
const SizedBox(height: AppSpacing.md),
_buildMemoryStatsRow(
icons: [
Icons.psychology_outlined,
Icons.build_outlined,
Icons.folder_outlined,
Icons.groups_outlined,
],
values: [
'${workMemory.expertise.length}',
'${workMemory.preferredTools.length}',
'${workMemory.currentProjects.length}',
'${workMemory.teamMembers.length}',
],
labels: [
context.l10n.memoryStatExpertise,
context.l10n.memoryStatTools,
context.l10n.memoryStatProjects,
context.l10n.memoryStatTeam,
],
),
],
],
),
),
);
}
Widget _buildMemoryStatsRow({
required List<IconData> icons,
required List<String> values,
required List<String> labels,
}) {
return Row(
children: List.generate(icons.length, (index) {
return Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
margin: EdgeInsets.only(
right: index < icons.length - 1 ? AppSpacing.sm : 0,
),
decoration: BoxDecoration(
color: AppColors.surfaceSecondary,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Column(
children: [
Icon(icons[index], size: 16, color: AppColors.slate400),
const SizedBox(height: 4),
Text(
values[index],
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.slate700,
),
),
const SizedBox(height: 2),
Text(
labels[index],
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: AppColors.slate400,
),
),
],
),
),
);
}),
);
}
}
@@ -0,0 +1,774 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:social_app/core/constants/app_constants.dart';
import 'package:social_app/app/di/injection.dart';
import 'package:social_app/app/router/app_routes.dart';
import 'package:social_app/core/l10n/l10n.dart';
import 'package:social_app/core/theme/design_tokens.dart';
import 'package:social_app/shared/widgets/app_button.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/destructive_action_sheet.dart';
import 'package:social_app/shared/widgets/toast/toast.dart';
import 'package:social_app/shared/widgets/toast/toast_type.dart';
import 'package:social_app/core/utils/phone_display_formatter.dart';
import 'package:social_app/features/auth/presentation/bloc/auth_bloc.dart';
import 'package:social_app/features/auth/presentation/bloc/auth_event.dart';
import 'package:social_app/features/auth/presentation/bloc/auth_state.dart';
import 'package:social_app/features/contacts/data/friends_api.dart';
import 'package:social_app/features/settings/data/settings_api.dart';
import 'package:social_app/features/settings/data/services/automation_jobs_api.dart';
import 'package:social_app/features/settings/data/services/settings_user_cache.dart';
import 'package:social_app/features/contacts/data/users/models/user_response.dart';
import 'package:social_app/features/home/presentation/navigation/home_return_policy.dart';
import '../widgets/settings_page_scaffold.dart';
const settingsProfileEditButtonKey = ValueKey('settings_profile_edit_button');
const settingsLogoutButtonKey = ValueKey('settings_logout_button');
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final FriendsApi _friendsApi = sl<FriendsApi>();
final AutomationJobsApi _automationJobsApi = sl<AutomationJobsApi>();
final SettingsUserCache _userCache = sl<SettingsUserCache>();
UserResponse? _user;
bool _isLoading = true;
int _friendsCount = 0;
String? _firstFriendName;
int _enabledJobsCount = 0;
String? _firstEnabledJobTitle;
@override
void initState() {
super.initState();
final cachedUser = _userCache.cachedUser;
if (cachedUser != null) {
_user = cachedUser;
_isLoading = false;
}
_loadData();
}
Future<void> _loadData() async {
try {
final user = await _userCache.getProfile();
if (mounted) {
setState(() {
_user = user;
_isLoading = false;
});
}
} catch (e) {
if (mounted && _user == null) {
setState(() {
_isLoading = false;
});
}
}
try {
final friends = await _friendsApi.getFriends();
if (mounted) {
setState(() {
_friendsCount = friends.length;
_firstFriendName = friends.isNotEmpty
? friends.first.friend.username
: null;
});
}
} catch (e) {
// Keep profile available even when contacts fail.
}
try {
final jobs = await _automationJobsApi.list();
final enabledJobs = jobs.where((job) => job.isActive).toList();
if (mounted) {
setState(() {
_enabledJobsCount = enabledJobs.length;
_firstEnabledJobTitle = enabledJobs.isNotEmpty
? enabledJobs.first.title
: null;
});
}
} catch (e) {
// Keep profile available even when automation jobs fail.
}
}
@override
Widget build(BuildContext context) {
return SettingsPageScaffold(
title: context.l10n.settingsTitle,
onBack: () => returnToHomePreserveState(context, forceGoHome: true),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildProfileHero(),
const SizedBox(height: AppSpacing.lg),
_buildQuickActions(context),
const SizedBox(height: AppSpacing.lg),
_buildSubscriptionCard(),
const SizedBox(height: AppSpacing.lg),
_buildMenuCard(context),
const SizedBox(height: AppSpacing.xl),
_buildLogoutAction(),
],
),
);
}
Widget _buildProfileHero() {
if (_isLoading) {
return Container(
width: double.infinity,
height: 120,
padding: const EdgeInsets.all(AppSpacing.xl),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.xxl),
),
child: const Center(child: AppLoadingIndicator(size: 22)),
);
}
final l10n = context.l10n;
final username = _user?.username ?? l10n.settingsUnset;
final phone = _user?.phone == null
? l10n.settingsUnset
: formatPhoneForDisplay(_user?.phone);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.xl),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.white, AppColors.surfaceInfoLight],
),
borderRadius: BorderRadius.circular(AppRadius.xxl),
border: Border.all(color: AppColors.borderTertiary),
boxShadow: [
BoxShadow(
color: AppColors.blue100.withValues(alpha: 0.35),
blurRadius: 14,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(32),
boxShadow: [
BoxShadow(
color: Color.fromRGBO(
AppColors.blue400.r.toInt(),
AppColors.blue400.g.toInt(),
AppColors.blue400.b.toInt(),
0.2,
),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
clipBehavior: Clip.antiAlias,
child: _buildAvatarImage(_user?.avatarUrl),
),
const SizedBox(width: AppSpacing.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
username,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Text(
phone,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
],
),
),
const SizedBox(width: AppSpacing.md),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
AppPressable(
key: settingsProfileEditButtonKey,
onTap: _onTapEditProfile,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: SizedBox(
width: AppSpacing.xl * 2,
height: AppSpacing.xl * 2,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(
Icons.edit,
size: 14,
color: AppColors.slate500,
),
const SizedBox(height: 3),
Container(
width: 12,
height: 1.5,
decoration: BoxDecoration(
color: AppColors.slate400,
borderRadius: BorderRadius.circular(
AppRadius.full,
),
),
),
],
),
),
),
const SizedBox(height: AppSpacing.sm),
_buildFreeBadge(),
],
),
],
),
],
),
);
}
Widget _buildFreeBadge() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppColors.blue50, AppColors.surfaceInfoLight],
),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.borderQuaternary),
),
child: Text(
context.l10n.settingsFreeBadge,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.blue600,
),
),
);
}
Widget _buildAvatarImage(String? avatarUrl) {
if (avatarUrl == null) {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.blue100, AppColors.blue50],
),
),
child: const Icon(Icons.person, size: 28, color: AppColors.blue600),
);
}
return Image.network(
avatarUrl,
fit: BoxFit.cover,
width: 64,
height: 64,
errorBuilder: (context, error, stackTrace) {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.blue100, AppColors.blue50],
),
),
child: const Icon(Icons.person, size: 28, color: AppColors.blue600),
);
},
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.blue100, AppColors.blue50],
),
),
child: const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(AppColors.blue600),
),
),
),
);
},
);
}
String _buildFriendsSubtitle() {
final l10n = context.l10n;
if (_friendsCount == 0) {
return l10n.settingsNoContacts;
}
if (_friendsCount == 1) {
return l10n.settingsContactsAddedOne(
_firstFriendName ?? l10n.commonUnknown,
);
}
return l10n.settingsContactsAddedMany(_friendsCount);
}
String _buildAutomationSubtitle() {
final l10n = context.l10n;
if (_enabledJobsCount == 0) {
return l10n.settingsNoEnabledPlans;
}
if (_enabledJobsCount == 1) {
return l10n.settingsEnabledPlanOne(
_firstEnabledJobTitle ?? l10n.settingsFeaturesTitle,
);
}
return l10n.settingsEnabledPlanMany(_enabledJobsCount);
}
Widget _buildQuickActions(BuildContext context) {
return Row(
children: [
Expanded(
child: _buildActionCard(
icon: Icons.people,
iconColor: AppColors.blue500,
title: context.l10n.contactsTitle,
subtitle: _buildFriendsSubtitle(),
onTap: () => context.push(AppRoutes.contactsList),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: _buildActionCard(
icon: Icons.auto_awesome,
iconColor: AppColors.blue500,
title: context.l10n.settingsFeaturesTitle,
subtitle: _buildAutomationSubtitle(),
onTap: () => context.push(AppRoutes.settingsFeatures),
),
),
],
);
}
Widget _buildActionCard({
required IconData icon,
required Color iconColor,
required String title,
required String subtitle,
required VoidCallback onTap,
}) {
return AppPressable(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.xl),
child: Container(
constraints: const BoxConstraints(minHeight: 136),
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderSecondary),
boxShadow: [
BoxShadow(
color: AppColors.slate200.withValues(alpha: 0.45),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.surfaceTertiary,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 18, color: iconColor),
),
const Spacer(),
Icon(Icons.chevron_right, size: 16, color: AppColors.slate300),
],
),
const SizedBox(height: AppSpacing.md),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
Widget _buildSubscriptionCard() {
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.white, AppColors.surfaceInfoLight],
),
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderTertiary),
boxShadow: [
BoxShadow(
color: AppColors.slate200.withValues(alpha: 0.4),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.blue100, AppColors.blue50],
),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppColors.blue200.withValues(alpha: 0.45),
blurRadius: 6,
offset: const Offset(0, 1),
),
],
),
child: const Icon(
Icons.workspace_premium,
size: 22,
color: AppColors.blue600,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.settingsUpgradeProTitle,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
const SizedBox(height: 2),
Text(
context.l10n.settingsUpgradeProDesc,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.blue500, AppColors.blue600],
),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: const Color(0x4D60A5FA),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Text(
context.l10n.settingsUpgradeButton,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.white,
),
),
),
],
),
);
}
Widget _buildMenuCard(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderSecondary),
),
child: Column(
children: [
_buildMenuItem(
icon: Icons.notifications,
title: context.l10n.settingsMenuNotifications,
onTap: () {},
),
_buildDivider(),
_buildMenuItem(
icon: Icons.bookmark,
title: context.l10n.memoryTitle,
onTap: () => context.push(AppRoutes.settingsMemory),
),
_buildDivider(),
_buildMenuItem(
icon: Icons.system_update,
title: context.l10n.settingsMenuCheckUpdates,
trailing: 'v${AppConstants.version}',
onTap: _checkForUpdates,
),
],
),
);
}
Widget _buildMenuItem({
required IconData icon,
required String title,
String? trailing,
required VoidCallback onTap,
}) {
return AppPressable(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(icon, size: 20, color: AppColors.slate500),
const SizedBox(width: 10),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppColors.slate900,
),
),
],
),
Row(
children: [
if (trailing != null) ...[
Text(
trailing,
style: const TextStyle(
fontSize: 14,
color: AppColors.slate400,
),
),
const SizedBox(width: 6),
],
const Icon(
Icons.chevron_right,
size: 18,
color: AppColors.slate400,
),
],
),
],
),
),
);
}
Widget _buildDivider() {
return Container(
height: 1,
margin: const EdgeInsets.symmetric(horizontal: 14),
color: AppColors.slate100,
);
}
Future<void> _onTapEditProfile() async {
final changed = await context.push<bool>(AppRoutes.settingsEditProfile);
if (changed == true && mounted) {
final cached = _userCache.cachedUser;
if (cached != null) {
setState(() {
_user = cached;
});
}
}
}
Future<void> _onTapLogout() async {
final confirmed = await showDestructiveActionSheet(
context,
title: context.l10n.settingsLogoutTitle,
message: context.l10n.settingsLogoutConfirmMessage,
confirmText: context.l10n.settingsLogoutConfirm,
);
if (!confirmed || !mounted) {
return;
}
_userCache.invalidate();
final authBloc = context.read<AuthBloc>();
authBloc.add(AuthLoggedOut());
try {
await authBloc.stream
.firstWhere((state) => state is AuthUnauthenticated)
.timeout(const Duration(seconds: 5));
} catch (_) {
if (!mounted) return;
Toast.show(
context,
context.l10n.settingsLogoutFailed,
type: ToastType.error,
);
return;
}
if (!mounted) return;
context.go(AppRoutes.authLogin);
}
Future<void> _checkForUpdates() async {
try {
final settingsApi = sl<SettingsApi>();
final result = await settingsApi.checkUpdates(
currentVersionCode: AppConstants.build,
currentVersionName: AppConstants.version,
platform: 'android',
);
if (!mounted) return;
if (!result.hasUpdate) {
Toast.show(
context,
context.l10n.settingsLatestVersion,
type: ToastType.success,
);
return;
}
final message = result.updateType == 'required'
? context.l10n.settingsUpdateRequired(result.latestVersionName)
: context.l10n.settingsUpdateOptional(result.latestVersionName);
final shouldUpdate = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.settingsUpdateDialogTitle),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.commonCancel),
),
if (result.downloadUrl != null)
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.settingsUpdateAction),
),
],
),
);
if (shouldUpdate == true && result.downloadUrl != null && mounted) {
Toast.show(
context,
context.l10n.settingsDownloadLink(result.downloadUrl!),
type: ToastType.info,
);
}
} catch (e) {
if (!mounted) return;
Toast.show(
context,
context.l10n.settingsUpdateCheckFailed,
type: ToastType.error,
);
}
}
Widget _buildLogoutAction() {
return SizedBox(
width: double.infinity,
height: 52,
child: AppButton(
key: settingsLogoutButtonKey,
text: context.l10n.settingsLogoutTitle,
isOutlined: true,
onPressed: () => _onTapLogout(),
),
);
}
}
@@ -0,0 +1,953 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:social_app/app/di/injection.dart';
import 'package:social_app/core/l10n/l10n.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/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';
class UserMemoryDetailScreen extends StatefulWidget {
const UserMemoryDetailScreen({super.key});
@override
State<UserMemoryDetailScreen> createState() => _UserMemoryDetailScreenState();
}
class _UserMemoryDetailScreenState extends State<UserMemoryDetailScreen> {
final MemoryService _memoryService = sl<MemoryService>();
UserMemoryContent? _memory;
bool _isLoading = true;
bool _isSaving = false;
String? _error;
bool _hasChanges = false;
@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 (e) {
if (!mounted) return;
setState(() {
_error = L10n.current.memoryLoadFailedRetry;
_isLoading = false;
});
}
}
Future<void> _saveMemory() async {
if (_memory == null || !_hasChanges) return;
if (!mounted) return;
setState(() {
_isSaving = true;
});
try {
await _memoryService.updateUserMemory(_memory!);
if (!mounted) return;
setState(() {
_isSaving = false;
_hasChanges = false;
});
Toast.show(
context,
context.l10n.settingsMemorySaveSuccess,
type: ToastType.success,
);
} catch (e) {
if (!mounted) return;
setState(() {
_isSaving = false;
});
Toast.show(
context,
context.l10n.settingsMemorySaveFailed,
type: ToastType.error,
);
}
}
void _updateMemory(UserMemoryContent newMemory) {
setState(() {
_memory = newMemory;
_hasChanges = true;
});
}
@override
Widget build(BuildContext context) {
return SettingsPageScaffold(
title: context.l10n.settingsUserMemoryEditTitle,
onBack: () => context.pop(),
footer: _hasChanges ? _buildSaveButton() : null,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_isLoading) ...[
const SizedBox(height: AppSpacing.xxl * 2),
_buildLoadingState(),
] else if (_error != null) ...[
const SizedBox(height: AppSpacing.xxl * 2),
_buildErrorState(),
] else if (_memory != null) ...[
_buildContent(),
] else ...[
const SizedBox(height: AppSpacing.xxl * 2),
_buildEmptyState(),
],
],
),
);
}
Widget _buildLoadingState() {
return const Center(child: AppLoadingIndicator(size: 32));
}
Widget _buildErrorState() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: AppColors.slate300),
const SizedBox(height: AppSpacing.md),
Text(
_error ?? context.l10n.memoryLoadFailedRetry,
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: Text(
context.l10n.memoryReload,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.blue600,
),
),
),
),
],
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.person_off_outlined, size: 48, color: AppColors.slate300),
const SizedBox(height: AppSpacing.md),
Text(
context.l10n.settingsUserMemoryEmptyProfile,
style: TextStyle(color: AppColors.slate500),
),
],
),
);
}
Widget _buildSaveButton() {
return SizedBox(
width: double.infinity,
height: 52,
child: AppPressable(
onTap: _isSaving ? null : _saveMemory,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Container(
height: 52,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.blue500, AppColors.blue600],
),
borderRadius: BorderRadius.circular(AppRadius.lg),
boxShadow: [
BoxShadow(
color: AppColors.blue500.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Center(
child: _isSaving
? const AppLoadingIndicator(variant: AppLoadingVariant.button)
: Text(
context.l10n.todoSaveChanges,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.white,
),
),
),
),
),
);
}
Widget _buildContent() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildBasicInfoSection(),
const SizedBox(height: AppSpacing.lg),
_buildPeopleSection(),
const SizedBox(height: AppSpacing.lg),
_buildPlacesSection(),
const SizedBox(height: AppSpacing.lg),
_buildPreferencesSection(),
const SizedBox(height: AppSpacing.lg),
_buildInterestsSection(),
const SizedBox(height: AppSpacing.lg),
_buildAvoidTopicsSection(),
const SizedBox(height: AppSpacing.lg),
_buildRecurringRoutinesSection(),
const SizedBox(height: AppSpacing.xxl),
],
);
}
Widget _buildBasicInfoSection() {
return _buildSection(
title: context.l10n.settingsUserMemorySectionBasic,
icon: Icons.person_outline,
children: [
_buildEditField(
label: context.l10n.settingsUserMemoryFieldOccupation,
value: _memory?.occupation,
onChanged: (value) =>
_updateMemory(_memory!.copyWith(occupation: value)),
),
_buildEditField(
label: context.l10n.settingsUserMemoryFieldTimezone,
value: _memory?.timezone,
onChanged: (value) =>
_updateMemory(_memory!.copyWith(timezone: value)),
),
_buildEditField(
label: context.l10n.settingsUserMemoryFieldPrimaryLanguage,
value: _memory?.primaryLanguage,
onChanged: (value) =>
_updateMemory(_memory!.copyWith(primaryLanguage: value)),
),
],
);
}
Widget _buildPeopleSection() {
return _buildSection(
title: context.l10n.settingsUserMemorySectionContacts,
icon: Icons.people_outline,
count: _memory?.people.length ?? 0,
children: [
if (_memory?.people.isEmpty ?? true)
_buildEmptySection(context.l10n.settingsUserMemoryEmptyContacts)
else
..._memory!.people.asMap().entries.map((entry) {
final index = entry.key;
final person = entry.value;
return _buildPersonItem(person, index);
}),
const SizedBox(height: AppSpacing.sm),
_buildAddButton(context.l10n.settingsUserMemoryAddContact, () {
final newPeople = List<Person>.from(_memory!.people)
..add(Person(name: context.l10n.settingsUserMemoryNewContact));
_updateMemory(_memory!.copyWith(people: newPeople));
}),
],
);
}
Widget _buildPersonItem(Person person, int index) {
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.borderSecondary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: _buildEditField(
label: context.l10n.settingsUserMemoryFieldName,
value: person.name,
onChanged: (value) {
final newPeople = List<Person>.from(_memory!.people);
newPeople[index] = person.copyWith(name: value);
_updateMemory(_memory!.copyWith(people: newPeople));
},
),
),
AppPressable(
onTap: () {
final newPeople = List<Person>.from(_memory!.people)
..removeAt(index);
_updateMemory(_memory!.copyWith(people: newPeople));
},
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Container(
padding: const EdgeInsets.all(AppSpacing.xs),
child: Icon(Icons.close, size: 18, color: AppColors.slate400),
),
),
],
),
const SizedBox(height: AppSpacing.sm),
Row(
children: [
Expanded(
child: _buildEditField(
label: context.l10n.settingsUserMemoryFieldRelationship,
value: person.relationship,
onChanged: (value) {
final newPeople = List<Person>.from(_memory!.people);
newPeople[index] = person.copyWith(relationship: value);
_updateMemory(_memory!.copyWith(people: newPeople));
},
),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: _buildEditField(
label: context.l10n.settingsUserMemoryFieldRole,
value: person.role,
onChanged: (value) {
final newPeople = List<Person>.from(_memory!.people);
newPeople[index] = person.copyWith(role: value);
_updateMemory(_memory!.copyWith(people: newPeople));
},
),
),
],
),
const SizedBox(height: AppSpacing.sm),
_buildEditField(
label: context.l10n.settingsUserMemoryFieldContact,
value: person.preferredContactChannel,
onChanged: (value) {
final newPeople = List<Person>.from(_memory!.people);
newPeople[index] = person.copyWith(
preferredContactChannel: value,
);
_updateMemory(_memory!.copyWith(people: newPeople));
},
),
const SizedBox(height: AppSpacing.sm),
_buildEditField(
label: context.l10n.settingsUserMemoryFieldNotes,
value: person.notes,
onChanged: (value) {
final newPeople = List<Person>.from(_memory!.people);
newPeople[index] = person.copyWith(notes: value);
_updateMemory(_memory!.copyWith(people: newPeople));
},
),
],
),
);
}
Widget _buildPlacesSection() {
return _buildSection(
title: context.l10n.settingsUserMemorySectionPlaces,
icon: Icons.place_outlined,
count: _memory?.places.length ?? 0,
children: [
if (_memory?.places.isEmpty ?? true)
_buildEmptySection(context.l10n.settingsUserMemoryEmptyPlaces)
else
..._memory!.places.asMap().entries.map((entry) {
final index = entry.key;
final place = entry.value;
return _buildPlaceItem(place, index);
}),
const SizedBox(height: AppSpacing.sm),
_buildAddButton(context.l10n.settingsUserMemoryAddPlace, () {
final newPlaces = List<Place>.from(_memory!.places)
..add(Place(name: context.l10n.settingsUserMemoryNewPlace));
_updateMemory(_memory!.copyWith(places: newPlaces));
}),
],
);
}
Widget _buildPlaceItem(Place place, int index) {
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.borderSecondary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: _buildEditField(
label: context.l10n.settingsUserMemoryFieldName,
value: place.name,
onChanged: (value) {
final newPlaces = List<Place>.from(_memory!.places);
newPlaces[index] = place.copyWith(name: value);
_updateMemory(_memory!.copyWith(places: newPlaces));
},
),
),
AppPressable(
onTap: () {
final newPlaces = List<Place>.from(_memory!.places)
..removeAt(index);
_updateMemory(_memory!.copyWith(places: newPlaces));
},
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Container(
padding: const EdgeInsets.all(AppSpacing.xs),
child: Icon(Icons.close, size: 18, color: AppColors.slate400),
),
),
],
),
const SizedBox(height: AppSpacing.sm),
Row(
children: [
Expanded(
child: _buildEditField(
label: context.l10n.settingsUserMemoryFieldCategory,
value: place.category,
onChanged: (value) {
final newPlaces = List<Place>.from(_memory!.places);
newPlaces[index] = place.copyWith(category: value);
_updateMemory(_memory!.copyWith(places: newPlaces));
},
),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: _buildEditField(
label: context.l10n.settingsUserMemoryFieldPreference,
value: place.preference,
onChanged: (value) {
final newPlaces = List<Place>.from(_memory!.places);
newPlaces[index] = place.copyWith(preference: value);
_updateMemory(_memory!.copyWith(places: newPlaces));
},
),
),
],
),
const SizedBox(height: AppSpacing.sm),
_buildEditField(
label: context.l10n.settingsUserMemoryFieldAddress,
value: place.address,
onChanged: (value) {
final newPlaces = List<Place>.from(_memory!.places);
newPlaces[index] = place.copyWith(address: value);
_updateMemory(_memory!.copyWith(places: newPlaces));
},
),
],
),
);
}
Widget _buildPreferencesSection() {
final prefs = _memory!.preferences;
return _buildSection(
title: context.l10n.settingsUserMemorySectionPreferences,
icon: Icons.settings_outlined,
children: [
_buildEditField(
label: context.l10n.settingsUserMemoryFieldCommunicationStyle,
value: prefs.communicationStyle,
onChanged: (value) {
_updateMemory(
_memory!.copyWith(
preferences: prefs.copyWith(communicationStyle: value),
),
);
},
),
_buildEditField(
label: context.l10n.settingsUserMemoryFieldLocationPreference,
value: prefs.locationPreference,
onChanged: (value) {
_updateMemory(
_memory!.copyWith(
preferences: prefs.copyWith(locationPreference: value),
),
);
},
),
_buildEditField(
label: context.l10n.settingsUserMemoryFieldWorkLifestyle,
value: prefs.workLifestyle,
onChanged: (value) {
_updateMemory(
_memory!.copyWith(
preferences: prefs.copyWith(workLifestyle: value),
),
);
},
),
],
);
}
Widget _buildInterestsSection() {
return _buildSection(
title: context.l10n.settingsUserMemorySectionInterests,
icon: Icons.interests_outlined,
children: [
_buildTagsSection(
tags: _memory?.interests ?? [],
onAdd: (tag) {
_updateMemory(
_memory!.copyWith(interests: [..._memory!.interests, tag]),
);
},
onRemove: (index) {
final newInterests = List<String>.from(_memory!.interests)
..removeAt(index);
_updateMemory(_memory!.copyWith(interests: newInterests));
},
),
],
);
}
Widget _buildAvoidTopicsSection() {
return _buildSection(
title: context.l10n.settingsUserMemorySectionAvoidTopics,
icon: Icons.not_interested_outlined,
children: [
_buildTagsSection(
tags: _memory?.avoidTopics ?? [],
onAdd: (tag) {
_updateMemory(
_memory!.copyWith(avoidTopics: [..._memory!.avoidTopics, tag]),
);
},
onRemove: (index) {
final newTopics = List<String>.from(_memory!.avoidTopics)
..removeAt(index);
_updateMemory(_memory!.copyWith(avoidTopics: newTopics));
},
),
],
);
}
Widget _buildRecurringRoutinesSection() {
return _buildSection(
title: context.l10n.settingsUserMemorySectionRoutines,
icon: Icons.schedule_outlined,
count: _memory?.recurringRoutines.length ?? 0,
children: [
if (_memory?.recurringRoutines.isEmpty ?? true)
_buildEmptySection(context.l10n.settingsUserMemoryEmptyRoutines)
else
..._memory!.recurringRoutines.asMap().entries.map((entry) {
final index = entry.key;
final routine = entry.value;
return _buildRoutineItem(routine, index);
}),
const SizedBox(height: AppSpacing.sm),
_buildAddButton(context.l10n.settingsUserMemoryAddRoutine, () {
final newRoutines =
List<RecurringRoutine>.from(_memory!.recurringRoutines)..add(
RecurringRoutine(
name: context.l10n.settingsUserMemoryNewRoutine,
),
);
_updateMemory(_memory!.copyWith(recurringRoutines: newRoutines));
}),
],
);
}
Widget _buildRoutineItem(RecurringRoutine routine, int index) {
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.borderSecondary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: _buildEditField(
label: context.l10n.settingsUserMemoryFieldName,
value: routine.name,
onChanged: (value) {
final newRoutines = List<RecurringRoutine>.from(
_memory!.recurringRoutines,
);
newRoutines[index] = routine.copyWith(name: value);
_updateMemory(
_memory!.copyWith(recurringRoutines: newRoutines),
);
},
),
),
AppPressable(
onTap: () {
final newRoutines = List<RecurringRoutine>.from(
_memory!.recurringRoutines,
)..removeAt(index);
_updateMemory(
_memory!.copyWith(recurringRoutines: newRoutines),
);
},
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Container(
padding: const EdgeInsets.all(AppSpacing.xs),
child: Icon(Icons.close, size: 18, color: AppColors.slate400),
),
),
],
),
const SizedBox(height: AppSpacing.sm),
Row(
children: [
Expanded(
child: _buildEditField(
label: context.l10n.settingsUserMemoryFieldDescription,
value: routine.description,
onChanged: (value) {
final newRoutines = List<RecurringRoutine>.from(
_memory!.recurringRoutines,
);
newRoutines[index] = routine.copyWith(description: value);
_updateMemory(
_memory!.copyWith(recurringRoutines: newRoutines),
);
},
),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: _buildEditField(
label: context.l10n.settingsUserMemoryFieldCadence,
value: routine.cadence,
onChanged: (value) {
final newRoutines = List<RecurringRoutine>.from(
_memory!.recurringRoutines,
);
newRoutines[index] = routine.copyWith(cadence: value);
_updateMemory(
_memory!.copyWith(recurringRoutines: newRoutines),
);
},
),
),
],
),
],
),
);
}
Widget _buildSection({
required String title,
required IconData icon,
int? count,
required List<Widget> children,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: AppColors.blue500),
const SizedBox(width: AppSpacing.sm),
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.slate800,
),
),
if (count != null) ...[
const SizedBox(width: AppSpacing.xs),
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.blue50,
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
'$count',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.blue600,
),
),
),
],
],
),
const SizedBox(height: AppSpacing.md),
...children,
],
);
}
Widget _buildEditField({
required String label,
String? value,
required ValueChanged<String> onChanged,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
const SizedBox(height: AppSpacing.xs),
Container(
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: AppColors.borderSecondary),
),
child: TextFormField(
initialValue: value,
onChanged: onChanged,
style: const TextStyle(fontSize: 14, color: AppColors.slate800),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
border: InputBorder.none,
hintText: context.l10n.settingsMemoryInputHint(label),
hintStyle: TextStyle(color: AppColors.slate400, fontSize: 14),
),
),
),
],
);
}
Widget _buildEmptySection(String message) {
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.surfaceSecondary,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: AppColors.borderSecondary),
),
child: Center(
child: Text(
message,
style: TextStyle(fontSize: 14, color: AppColors.slate400),
),
),
);
}
Widget _buildTagsSection({
required List<String> tags,
required ValueChanged<String> onAdd,
required ValueChanged<int> onRemove,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
...tags.asMap().entries.map((entry) {
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,
children: [
Text(
entry.value,
style: const TextStyle(
fontSize: 13,
color: AppColors.blue600,
),
),
const SizedBox(width: AppSpacing.xs),
AppPressable(
onTap: () => onRemove(entry.key),
borderRadius: BorderRadius.circular(AppRadius.full),
child: Icon(
Icons.close,
size: 14,
color: AppColors.blue400,
),
),
],
),
);
}),
AppPressable(
onTap: () => _showAddTagDialog(onAdd),
borderRadius: BorderRadius.circular(AppRadius.full),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: AppColors.surfaceSecondary,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(
color: AppColors.borderSecondary,
style: BorderStyle.solid,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, size: 14, color: AppColors.slate500),
const SizedBox(width: AppSpacing.xs),
Text(
context.l10n.contactsAdd,
style: TextStyle(fontSize: 13, color: AppColors.slate500),
),
],
),
),
),
],
),
],
);
}
void _showAddTagDialog(ValueChanged<String> onAdd) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.contactsAdd),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(
hintText: context.l10n.settingsMemoryInputContent,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.commonCancel),
),
TextButton(
onPressed: () {
if (controller.text.isNotEmpty) {
onAdd(controller.text);
}
Navigator.pop(context);
},
child: Text(context.l10n.contactsAdd),
),
],
),
);
}
Widget _buildAddButton(String text, VoidCallback onTap) {
return AppPressable(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.surfaceSecondary,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(
color: AppColors.borderSecondary,
style: BorderStyle.solid,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add, size: 18, color: AppColors.blue500),
const SizedBox(width: AppSpacing.xs),
Text(
text,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.blue600,
),
),
],
),
),
);
}
}
@@ -0,0 +1,587 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:social_app/app/di/injection.dart';
import 'package:social_app/app/router/app_routes.dart';
import 'package:social_app/core/l10n/l10n.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 = L10n.current.memoryLoadFailedRetry;
_isLoading = false;
});
}
}
void _onHeaderAction(_UserMemoryHeaderAction action) {
switch (action) {
case _UserMemoryHeaderAction.edit:
context.push(AppRoutes.settingsMemoryUserEdit);
}
}
@override
Widget build(BuildContext context) {
return SettingsPageScaffold(
title: context.l10n.memoryUserProfile,
onBack: () => context.pop(),
trailing: DetailHeaderActionMenu<_UserMemoryHeaderAction>(
items: [
DetailHeaderActionItem<_UserMemoryHeaderAction>(
value: _UserMemoryHeaderAction.edit,
label: context.l10n.commonEdit,
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 ?? context.l10n.memoryLoadFailedRetry,
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: Text(
context.l10n.memoryReload,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.blue600,
),
),
),
),
],
),
);
}
Widget _buildContent(UserMemoryContent memory) {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSectionCard(
title: l10n.settingsUserMemorySectionBasic,
icon: Icons.person_outline,
children: [
_buildInfoRow(
Icons.work_outline,
l10n.settingsUserMemoryFieldOccupation,
_text(memory.occupation),
),
_buildInfoRow(
Icons.public_outlined,
l10n.settingsUserMemoryFieldTimezone,
_text(memory.timezone),
),
_buildInfoRow(
Icons.translate_outlined,
l10n.settingsUserMemoryFieldPrimaryLanguage,
_text(memory.primaryLanguage),
),
],
),
const SizedBox(height: AppSpacing.md),
_buildSectionCard(
title: l10n.settingsUserMemorySectionPreferences,
icon: Icons.tune,
children: [
_buildInfoRow(
Icons.chat_bubble_outline,
l10n.settingsUserMemoryFieldCommunicationStyle,
_text(memory.preferences.communicationStyle),
),
_buildInfoRow(
Icons.place_outlined,
l10n.settingsUserMemoryFieldLocationPreference,
_text(memory.preferences.locationPreference),
),
_buildInfoRow(
Icons.balcony_outlined,
l10n.settingsUserMemoryFieldWorkLifestyle,
_text(memory.preferences.workLifestyle),
),
_buildInfoRow(
Icons.language_outlined,
l10n.settingsUserMemoryFieldLanguagePreference,
_listText(memory.preferences.languagePreference),
),
_buildInfoRow(
Icons.notifications_outlined,
l10n.settingsUserMemoryFieldNotificationPreference,
_listText(memory.preferences.notificationPreference),
),
],
),
const SizedBox(height: AppSpacing.md),
_buildSectionCard(
title: l10n.settingsUserMemorySectionSchedule,
icon: Icons.schedule_outlined,
children: [
_buildInfoRow(
Icons.timer_outlined,
l10n.settingsUserMemoryFieldMeetingBuffer,
_minutesText(memory.schedulingPreferences.meetingBufferMinutes),
),
_buildInfoRow(
Icons.format_list_numbered,
l10n.settingsUserMemoryFieldMaxMeetingsPerDay,
_numberText(memory.schedulingPreferences.maxMeetingsPerDay),
),
_buildInfoRow(
Icons.timelapse_outlined,
l10n.settingsUserMemoryFieldPreferredMeetingDuration,
_intListText(
memory.schedulingPreferences.preferredMeetingDurationMinutes,
suffix: l10n.settingsUserMemoryMinute,
),
),
_buildInfoRow(
Icons.note_outlined,
l10n.settingsUserMemoryFieldNotes,
_text(memory.schedulingPreferences.notes),
multiline: true,
),
],
),
const SizedBox(height: AppSpacing.md),
_buildSectionCard(
title: l10n.settingsUserMemorySectionContacts,
icon: Icons.people_outline,
children: [_buildPeople(memory.people)],
),
const SizedBox(height: AppSpacing.md),
_buildSectionCard(
title: l10n.settingsUserMemorySectionPlaces,
icon: Icons.place_outlined,
children: [_buildPlaces(memory.places)],
),
const SizedBox(height: AppSpacing.md),
_buildSectionCard(
title: l10n.settingsUserMemorySectionInterests,
icon: Icons.interests_outlined,
children: [_buildTags(memory.interests)],
),
const SizedBox(height: AppSpacing.md),
_buildSectionCard(
title: l10n.settingsUserMemorySectionAvoidTopics,
icon: Icons.not_interested_outlined,
children: [_buildTags(memory.avoidTopics)],
),
const SizedBox(height: AppSpacing.md),
_buildSectionCard(
title: l10n.settingsUserMemorySectionCustomRules,
icon: Icons.rule_folder_outlined,
children: [_buildTags(memory.customRules)],
),
const SizedBox(height: AppSpacing.md),
_buildSectionCard(
title: l10n.settingsUserMemorySectionRoutines,
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(context.l10n.settingsUserMemoryEmptyContacts);
}
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,
context.l10n.settingsUserMemoryFieldName,
_text(person.name),
),
_buildInfoRow(
Icons.account_tree_outlined,
context.l10n.settingsUserMemoryFieldRelationship,
_text(person.relationship),
),
_buildInfoRow(
Icons.person_pin_outlined,
context.l10n.settingsUserMemoryFieldRole,
_text(person.role),
),
_buildInfoRow(
Icons.phone_outlined,
context.l10n.settingsUserMemoryFieldContact,
_text(person.preferredContactChannel),
),
_buildInfoRow(
Icons.note_outlined,
context.l10n.settingsUserMemoryFieldNotes,
_text(person.notes),
multiline: true,
),
],
),
);
}).toList(),
);
}
Widget _buildPlaces(List<Place> places) {
if (places.isEmpty) {
return _buildEmptyTip(context.l10n.settingsUserMemoryEmptyPlaces);
}
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,
context.l10n.settingsUserMemoryFieldName,
_text(place.name),
),
_buildInfoRow(
Icons.category_outlined,
context.l10n.settingsUserMemoryFieldCategory,
_text(place.category),
),
_buildInfoRow(
Icons.favorite_border,
context.l10n.settingsUserMemoryFieldPreference,
_text(place.preference),
),
_buildInfoRow(
Icons.map_outlined,
context.l10n.settingsUserMemoryFieldAddress,
_text(place.address),
),
],
),
);
}).toList(),
);
}
Widget _buildRoutines(List<RecurringRoutine> routines) {
if (routines.isEmpty) {
return _buildEmptyTip(context.l10n.settingsUserMemoryEmptyRoutines);
}
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,
context.l10n.settingsUserMemoryFieldName,
_text(routine.name),
),
_buildInfoRow(
Icons.subject_outlined,
context.l10n.settingsUserMemoryFieldDescription,
_text(routine.description),
multiline: true,
),
_buildInfoRow(
Icons.repeat_one,
context.l10n.settingsUserMemoryFieldCadence,
_text(routine.cadence),
),
],
),
);
}).toList(),
);
}
Widget _buildTags(List<String> tags) {
if (tags.isEmpty) {
return _buildEmptyTip(context.l10n.memoryNoInfo);
}
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 ? context.l10n.settingsUnset : raw;
}
String _listText(List<String> values) {
if (values.isEmpty) return context.l10n.settingsUnset;
return values.join('');
}
String _intListText(List<int> values, {required String suffix}) {
if (values.isEmpty) return context.l10n.settingsUnset;
return values.map((value) => '$value$suffix').join('');
}
String _minutesText(int? value) {
if (value == null) return context.l10n.settingsUnset;
return context.l10n.settingsUserMemoryMinutesValue(value);
}
String _numberText(int? value) {
if (value == null) return context.l10n.settingsUnset;
return '$value';
}
}
@@ -0,0 +1,898 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:social_app/app/di/injection.dart';
import 'package:social_app/core/l10n/l10n.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/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';
class WorkMemoryDetailScreen extends StatefulWidget {
const WorkMemoryDetailScreen({super.key});
@override
State<WorkMemoryDetailScreen> createState() => _WorkMemoryDetailScreenState();
}
class _WorkMemoryDetailScreenState extends State<WorkMemoryDetailScreen> {
final MemoryService _memoryService = sl<MemoryService>();
WorkProfileContent? _memory;
bool _isLoading = true;
bool _isSaving = false;
String? _error;
bool _hasChanges = false;
@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 (e) {
if (!mounted) return;
setState(() {
_error = L10n.current.memoryLoadFailedRetry;
_isLoading = false;
});
}
}
Future<void> _saveMemory() async {
if (_memory == null || !_hasChanges) return;
if (!mounted) return;
setState(() {
_isSaving = true;
});
try {
await _memoryService.updateWorkMemory(_memory!);
if (!mounted) return;
setState(() {
_isSaving = false;
_hasChanges = false;
});
Toast.show(
context,
context.l10n.settingsMemorySaveSuccess,
type: ToastType.success,
);
} catch (e) {
if (!mounted) return;
setState(() {
_isSaving = false;
});
Toast.show(
context,
context.l10n.settingsMemorySaveFailed,
type: ToastType.error,
);
}
}
void _updateMemory(WorkProfileContent newMemory) {
setState(() {
_memory = newMemory;
_hasChanges = true;
});
}
@override
Widget build(BuildContext context) {
return SettingsPageScaffold(
title: context.l10n.settingsWorkMemoryEditTitle,
onBack: () => context.pop(),
footer: _hasChanges ? _buildSaveButton() : null,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_isLoading) ...[
const SizedBox(height: AppSpacing.xxl * 2),
_buildLoadingState(),
] else if (_error != null) ...[
const SizedBox(height: AppSpacing.xxl * 2),
_buildErrorState(),
] else if (_memory != null) ...[
_buildContent(),
] else ...[
const SizedBox(height: AppSpacing.xxl * 2),
_buildEmptyState(),
],
],
),
);
}
Widget _buildLoadingState() {
return const Center(child: AppLoadingIndicator(size: 32));
}
Widget _buildErrorState() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: AppColors.slate300),
const SizedBox(height: AppSpacing.md),
Text(
_error ?? context.l10n.memoryLoadFailedRetry,
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: Text(
context.l10n.memoryReload,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.blue600,
),
),
),
),
],
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.work_off_outlined, size: 48, color: AppColors.slate300),
const SizedBox(height: AppSpacing.md),
Text(
context.l10n.settingsWorkMemoryEmptyProfile,
style: TextStyle(color: AppColors.slate500),
),
],
),
);
}
Widget _buildSaveButton() {
return SizedBox(
width: double.infinity,
height: 52,
child: AppPressable(
onTap: _isSaving ? null : _saveMemory,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Container(
height: 52,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.blue500, AppColors.blue600],
),
borderRadius: BorderRadius.circular(AppRadius.lg),
boxShadow: [
BoxShadow(
color: AppColors.blue500.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Center(
child: _isSaving
? const AppLoadingIndicator(variant: AppLoadingVariant.button)
: Text(
context.l10n.todoSaveChanges,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.white,
),
),
),
),
),
);
}
Widget _buildContent() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildBasicInfoSection(),
const SizedBox(height: AppSpacing.lg),
_buildExpertiseSection(),
const SizedBox(height: AppSpacing.lg),
_buildPreferredToolsSection(),
const SizedBox(height: AppSpacing.lg),
_buildProjectsSection(),
const SizedBox(height: AppSpacing.lg),
_buildTeamMembersSection(),
const SizedBox(height: AppSpacing.lg),
_buildWorkHabitsSection(),
const SizedBox(height: AppSpacing.lg),
_buildTeamContextSection(),
const SizedBox(height: AppSpacing.lg),
_buildWorkRulesSection(),
const SizedBox(height: AppSpacing.xxl),
],
);
}
Widget _buildBasicInfoSection() {
return _buildSection(
title: context.l10n.settingsWorkMemorySectionBasic,
icon: Icons.work_outline,
children: [
_buildEditField(
label: context.l10n.settingsWorkMemoryFieldOccupation,
value: _memory?.occupation,
onChanged: (value) =>
_updateMemory(_memory!.copyWith(occupation: value)),
),
],
);
}
Widget _buildExpertiseSection() {
return _buildSection(
title: context.l10n.settingsWorkMemorySectionExpertise,
icon: Icons.psychology_outlined,
count: _memory?.expertise.length ?? 0,
children: [
_buildTagsSection(
tags: _memory?.expertise ?? [],
onAdd: (tag) {
_updateMemory(
_memory!.copyWith(expertise: [..._memory!.expertise, tag]),
);
},
onRemove: (index) {
final newExpertise = List<String>.from(_memory!.expertise)
..removeAt(index);
_updateMemory(_memory!.copyWith(expertise: newExpertise));
},
),
],
);
}
Widget _buildPreferredToolsSection() {
return _buildSection(
title: context.l10n.settingsWorkMemorySectionPreferredTools,
icon: Icons.build_outlined,
count: _memory?.preferredTools.length ?? 0,
children: [
_buildTagsSection(
tags: _memory?.preferredTools ?? [],
onAdd: (tag) {
_updateMemory(
_memory!.copyWith(
preferredTools: [..._memory!.preferredTools, tag],
),
);
},
onRemove: (index) {
final newTools = List<String>.from(_memory!.preferredTools)
..removeAt(index);
_updateMemory(_memory!.copyWith(preferredTools: newTools));
},
),
],
);
}
Widget _buildProjectsSection() {
return _buildSection(
title: context.l10n.settingsWorkMemorySectionCurrentProjects,
icon: Icons.folder_outlined,
count: _memory?.currentProjects.length ?? 0,
children: [
if (_memory?.currentProjects.isEmpty ?? true)
_buildEmptySection(context.l10n.settingsWorkMemoryEmptyProjects)
else
..._memory!.currentProjects.asMap().entries.map((entry) {
final index = entry.key;
final project = entry.value;
return _buildProjectItem(project, index);
}),
const SizedBox(height: AppSpacing.sm),
_buildAddButton(context.l10n.settingsWorkMemoryAddProject, () {
final newProjects =
List<CurrentProject>.from(_memory!.currentProjects)..add(
CurrentProject(name: context.l10n.settingsWorkMemoryNewProject),
);
_updateMemory(_memory!.copyWith(currentProjects: newProjects));
}),
],
);
}
Widget _buildProjectItem(CurrentProject project, int index) {
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.borderSecondary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: _buildEditField(
label: context.l10n.settingsWorkMemoryFieldProjectName,
value: project.name,
onChanged: (value) {
final newProjects = List<CurrentProject>.from(
_memory!.currentProjects,
);
newProjects[index] = project.copyWith(name: value);
_updateMemory(
_memory!.copyWith(currentProjects: newProjects),
);
},
),
),
AppPressable(
onTap: () {
final newProjects = List<CurrentProject>.from(
_memory!.currentProjects,
)..removeAt(index);
_updateMemory(
_memory!.copyWith(currentProjects: newProjects),
);
},
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Container(
padding: const EdgeInsets.all(AppSpacing.xs),
child: Icon(Icons.close, size: 18, color: AppColors.slate400),
),
),
],
),
const SizedBox(height: AppSpacing.sm),
Row(
children: [
Expanded(
child: _buildEditField(
label: context.l10n.settingsWorkMemoryFieldStatus,
value: project.status,
onChanged: (value) {
final newProjects = List<CurrentProject>.from(
_memory!.currentProjects,
);
newProjects[index] = project.copyWith(status: value);
_updateMemory(
_memory!.copyWith(currentProjects: newProjects),
);
},
),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: _buildEditField(
label: context.l10n.settingsWorkMemoryFieldPriority,
value: project.priority,
onChanged: (value) {
final newProjects = List<CurrentProject>.from(
_memory!.currentProjects,
);
newProjects[index] = project.copyWith(priority: value);
_updateMemory(
_memory!.copyWith(currentProjects: newProjects),
);
},
),
),
],
),
const SizedBox(height: AppSpacing.sm),
_buildEditField(
label: context.l10n.settingsUserMemoryFieldDescription,
value: project.description,
onChanged: (value) {
final newProjects = List<CurrentProject>.from(
_memory!.currentProjects,
);
newProjects[index] = project.copyWith(description: value);
_updateMemory(_memory!.copyWith(currentProjects: newProjects));
},
),
const SizedBox(height: AppSpacing.sm),
_buildEditField(
label: context.l10n.settingsWorkMemoryFieldDeadline,
value: project.deadline?.toIso8601String().split('T').first,
onChanged: (value) {
final newProjects = List<CurrentProject>.from(
_memory!.currentProjects,
);
newProjects[index] = project.copyWith(
deadline: value.isNotEmpty ? DateTime.tryParse(value) : null,
);
_updateMemory(_memory!.copyWith(currentProjects: newProjects));
},
),
],
),
);
}
Widget _buildTeamMembersSection() {
return _buildSection(
title: context.l10n.settingsWorkMemorySectionTeamMembers,
icon: Icons.groups_outlined,
count: _memory?.teamMembers.length ?? 0,
children: [
if (_memory?.teamMembers.isEmpty ?? true)
_buildEmptySection(context.l10n.settingsWorkMemoryEmptyTeamMembers)
else
..._memory!.teamMembers.asMap().entries.map((entry) {
final index = entry.key;
final member = entry.value;
return _buildTeamMemberItem(member, index);
}),
const SizedBox(height: AppSpacing.sm),
_buildAddButton(context.l10n.settingsWorkMemoryAddMember, () {
final newMembers = List<TeamMember>.from(_memory!.teamMembers)
..add(TeamMember(name: context.l10n.settingsWorkMemoryNewMember));
_updateMemory(_memory!.copyWith(teamMembers: newMembers));
}),
],
);
}
Widget _buildTeamMemberItem(TeamMember member, int index) {
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.borderSecondary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: _buildEditField(
label: context.l10n.settingsUserMemoryFieldName,
value: member.name,
onChanged: (value) {
final newMembers = List<TeamMember>.from(
_memory!.teamMembers,
);
newMembers[index] = member.copyWith(name: value);
_updateMemory(_memory!.copyWith(teamMembers: newMembers));
},
),
),
AppPressable(
onTap: () {
final newMembers = List<TeamMember>.from(_memory!.teamMembers)
..removeAt(index);
_updateMemory(_memory!.copyWith(teamMembers: newMembers));
},
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Container(
padding: const EdgeInsets.all(AppSpacing.xs),
child: Icon(Icons.close, size: 18, color: AppColors.slate400),
),
),
],
),
const SizedBox(height: AppSpacing.sm),
Row(
children: [
Expanded(
child: _buildEditField(
label: context.l10n.settingsUserMemoryFieldRole,
value: member.role,
onChanged: (value) {
final newMembers = List<TeamMember>.from(
_memory!.teamMembers,
);
newMembers[index] = member.copyWith(role: value);
_updateMemory(_memory!.copyWith(teamMembers: newMembers));
},
),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: _buildEditField(
label: context.l10n.settingsUserMemoryFieldRelationship,
value: member.relationship,
onChanged: (value) {
final newMembers = List<TeamMember>.from(
_memory!.teamMembers,
);
newMembers[index] = member.copyWith(relationship: value);
_updateMemory(_memory!.copyWith(teamMembers: newMembers));
},
),
),
],
),
const SizedBox(height: AppSpacing.sm),
_buildEditField(
label: context.l10n.settingsUserMemoryFieldContact,
value: member.preferredContactChannel,
onChanged: (value) {
final newMembers = List<TeamMember>.from(_memory!.teamMembers);
newMembers[index] = member.copyWith(
preferredContactChannel: value,
);
_updateMemory(_memory!.copyWith(teamMembers: newMembers));
},
),
const SizedBox(height: AppSpacing.sm),
_buildEditField(
label: context.l10n.settingsUserMemoryFieldNotes,
value: member.notes,
onChanged: (value) {
final newMembers = List<TeamMember>.from(_memory!.teamMembers);
newMembers[index] = member.copyWith(notes: value);
_updateMemory(_memory!.copyWith(teamMembers: newMembers));
},
),
],
),
);
}
Widget _buildWorkHabitsSection() {
final habits = _memory!.workHabits;
return _buildSection(
title: context.l10n.settingsWorkMemorySectionWorkHabits,
icon: Icons.schedule_outlined,
children: [
_buildEditField(
label: context.l10n.settingsWorkMemoryFieldNotificationChannel,
value: habits.notificationChannel,
onChanged: (value) {
_updateMemory(
_memory!.copyWith(
workHabits: habits.copyWith(notificationChannel: value),
),
);
},
),
const SizedBox(height: AppSpacing.sm),
_buildEditField(
label: context.l10n.settingsWorkMemoryFieldNotes,
value: habits.notes,
onChanged: (value) {
_updateMemory(
_memory!.copyWith(workHabits: habits.copyWith(notes: value)),
);
},
),
],
);
}
Widget _buildTeamContextSection() {
return _buildSection(
title: context.l10n.settingsWorkMemorySectionTeamContext,
icon: Icons.business_outlined,
children: [
_buildEditField(
label: context.l10n.settingsWorkMemoryFieldTeamContext,
value: _memory?.teamContext,
onChanged: (value) =>
_updateMemory(_memory!.copyWith(teamContext: value)),
),
],
);
}
Widget _buildWorkRulesSection() {
return _buildSection(
title: context.l10n.settingsWorkMemorySectionWorkRules,
icon: Icons.rule_outlined,
children: [
_buildTagsSection(
tags: _memory?.workRules ?? [],
onAdd: (tag) {
_updateMemory(
_memory!.copyWith(workRules: [..._memory!.workRules, tag]),
);
},
onRemove: (index) {
final newRules = List<String>.from(_memory!.workRules)
..removeAt(index);
_updateMemory(_memory!.copyWith(workRules: newRules));
},
),
],
);
}
Widget _buildSection({
required String title,
required IconData icon,
int? count,
required List<Widget> children,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: AppColors.violet500),
const SizedBox(width: AppSpacing.sm),
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.slate800,
),
),
if (count != null) ...[
const SizedBox(width: AppSpacing.xs),
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.violet500.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
'$count',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.violet600,
),
),
),
],
],
),
const SizedBox(height: AppSpacing.md),
...children,
],
);
}
Widget _buildEditField({
required String label,
String? value,
required ValueChanged<String> onChanged,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
const SizedBox(height: AppSpacing.xs),
Container(
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: AppColors.borderSecondary),
),
child: TextFormField(
initialValue: value,
onChanged: onChanged,
style: const TextStyle(fontSize: 14, color: AppColors.slate800),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
border: InputBorder.none,
hintText: context.l10n.settingsMemoryInputHint(label),
hintStyle: TextStyle(color: AppColors.slate400, fontSize: 14),
),
),
),
],
);
}
Widget _buildEmptySection(String message) {
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.surfaceSecondary,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: AppColors.borderSecondary),
),
child: Center(
child: Text(
message,
style: TextStyle(fontSize: 14, color: AppColors.slate400),
),
),
);
}
Widget _buildTagsSection({
required List<String> tags,
required ValueChanged<String> onAdd,
required ValueChanged<int> onRemove,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
...tags.asMap().entries.map((entry) {
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.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
entry.value,
style: TextStyle(
fontSize: 13,
color: AppColors.violet600,
),
),
const SizedBox(width: AppSpacing.xs),
AppPressable(
onTap: () => onRemove(entry.key),
borderRadius: BorderRadius.circular(AppRadius.full),
child: Icon(
Icons.close,
size: 14,
color: AppColors.violet500,
),
),
],
),
);
}),
AppPressable(
onTap: () => _showAddTagDialog(onAdd),
borderRadius: BorderRadius.circular(AppRadius.full),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: AppColors.surfaceSecondary,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(
color: AppColors.borderSecondary,
style: BorderStyle.solid,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, size: 14, color: AppColors.slate500),
const SizedBox(width: AppSpacing.xs),
Text(
context.l10n.contactsAdd,
style: TextStyle(fontSize: 13, color: AppColors.slate500),
),
],
),
),
),
],
),
],
);
}
void _showAddTagDialog(ValueChanged<String> onAdd) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.contactsAdd),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(
hintText: context.l10n.settingsMemoryInputContent,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.commonCancel),
),
TextButton(
onPressed: () {
if (controller.text.isNotEmpty) {
onAdd(controller.text);
}
Navigator.pop(context);
},
child: Text(context.l10n.contactsAdd),
),
],
),
);
}
Widget _buildAddButton(String text, VoidCallback onTap) {
return AppPressable(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.surfaceSecondary,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(
color: AppColors.borderSecondary,
style: BorderStyle.solid,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add, size: 18, color: AppColors.violet500),
const SizedBox(width: AppSpacing.xs),
Text(
text,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.violet600,
),
),
],
),
),
);
}
}
@@ -0,0 +1,549 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:social_app/app/di/injection.dart';
import 'package:social_app/app/router/app_routes.dart';
import 'package:social_app/core/l10n/l10n.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 = L10n.current.memoryLoadFailedRetry;
_isLoading = false;
});
}
}
void _onHeaderAction(_WorkMemoryHeaderAction action) {
switch (action) {
case _WorkMemoryHeaderAction.edit:
context.push(AppRoutes.settingsMemoryWorkEdit);
}
}
@override
Widget build(BuildContext context) {
return SettingsPageScaffold(
title: context.l10n.memoryWorkProfile,
onBack: () => context.pop(),
trailing: DetailHeaderActionMenu<_WorkMemoryHeaderAction>(
items: [
DetailHeaderActionItem<_WorkMemoryHeaderAction>(
value: _WorkMemoryHeaderAction.edit,
label: context.l10n.commonEdit,
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 ?? context.l10n.memoryLoadFailedRetry,
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: Text(
context.l10n.memoryReload,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.blue600,
),
),
),
),
],
),
);
}
Widget _buildContent(WorkProfileContent memory) {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSectionCard(
title: l10n.settingsWorkMemorySectionBasic,
icon: Icons.work_outline,
children: [
_buildInfoRow(
Icons.badge_outlined,
l10n.settingsWorkMemoryFieldOccupation,
_text(memory.occupation),
),
],
),
const SizedBox(height: AppSpacing.md),
_buildSectionCard(
title: l10n.settingsWorkMemorySectionExpertise,
icon: Icons.psychology_outlined,
children: [_buildTags(memory.expertise)],
),
const SizedBox(height: AppSpacing.md),
_buildSectionCard(
title: l10n.settingsWorkMemorySectionPreferredTools,
icon: Icons.build_outlined,
children: [_buildTags(memory.preferredTools)],
),
const SizedBox(height: AppSpacing.md),
_buildSectionCard(
title: l10n.settingsWorkMemorySectionCurrentProjects,
icon: Icons.folder_outlined,
children: [_buildProjects(memory.currentProjects)],
),
const SizedBox(height: AppSpacing.md),
_buildSectionCard(
title: l10n.settingsWorkMemorySectionTeamMembers,
icon: Icons.groups_outlined,
children: [_buildTeamMembers(memory.teamMembers)],
),
const SizedBox(height: AppSpacing.md),
_buildSectionCard(
title: l10n.settingsWorkMemorySectionWorkHabits,
icon: Icons.schedule_outlined,
children: [
_buildInfoRow(
Icons.timelapse_outlined,
l10n.settingsWorkMemoryFieldAvailableHours,
_timeWindowSummary(memory.workHabits.availableHours),
),
_buildInfoRow(
Icons.flash_on_outlined,
l10n.settingsWorkMemoryFieldDeepWorkBlocks,
_timeWindowSummary(memory.workHabits.deepWorkBlocks),
),
_buildInfoRow(
Icons.meeting_room_outlined,
l10n.settingsWorkMemoryFieldPreferredMeetingWindows,
_timeWindowSummary(memory.workHabits.preferredMeetingWindows),
),
_buildInfoRow(
Icons.do_not_disturb_alt_outlined,
l10n.settingsWorkMemoryFieldNoMeetingWindows,
_timeWindowSummary(memory.workHabits.noMeetingWindows),
),
_buildInfoRow(
Icons.timer_outlined,
l10n.settingsWorkMemoryFieldPreferredMeetingDuration,
_intListText(
memory.workHabits.preferredMeetingDurationMinutes,
suffix: l10n.settingsWorkMemoryMinute,
),
),
_buildInfoRow(
Icons.notifications_outlined,
l10n.settingsWorkMemoryFieldNotificationChannel,
_text(memory.workHabits.notificationChannel),
),
_buildInfoRow(
Icons.note_outlined,
l10n.settingsWorkMemoryFieldNotes,
_text(memory.workHabits.notes),
multiline: true,
),
],
),
const SizedBox(height: AppSpacing.md),
_buildSectionCard(
title: l10n.settingsWorkMemorySectionTeamContext,
icon: Icons.business_outlined,
children: [
_buildInfoRow(
Icons.apartment_outlined,
l10n.settingsWorkMemoryFieldTeamContext,
_text(memory.teamContext),
multiline: true,
),
],
),
const SizedBox(height: AppSpacing.md),
_buildSectionCard(
title: l10n.settingsWorkMemorySectionWorkRules,
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(context.l10n.settingsWorkMemoryEmptyProjects);
}
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,
context.l10n.settingsWorkMemoryFieldProjectName,
_text(project.name),
),
_buildInfoRow(
Icons.subject_outlined,
context.l10n.settingsUserMemoryFieldDescription,
_text(project.description),
multiline: true,
),
_buildInfoRow(
Icons.flag_outlined,
context.l10n.settingsWorkMemoryFieldStatus,
_text(project.status),
),
_buildInfoRow(
Icons.priority_high_outlined,
context.l10n.settingsWorkMemoryFieldPriority,
_text(project.priority),
),
_buildInfoRow(
Icons.event_outlined,
context.l10n.settingsWorkMemoryFieldDeadline,
project.deadline == null
? context.l10n.settingsUnset
: project.deadline!.toIso8601String().split('T').first,
),
_buildInfoRow(
Icons.group_add_outlined,
context.l10n.settingsWorkMemoryFieldCollaborators,
_listText(project.collaborators),
),
_buildInfoRow(
Icons.emoji_events_outlined,
context.l10n.settingsWorkMemoryFieldMilestones,
project.keyMilestones.isEmpty
? context.l10n.settingsUnset
: context.l10n.settingsWorkMemoryMilestoneCount(
project.keyMilestones.length,
),
),
_buildInfoRow(
Icons.note_alt_outlined,
context.l10n.settingsWorkMemoryFieldNotes,
_text(project.notes),
),
],
),
);
}).toList(),
);
}
Widget _buildTeamMembers(List<TeamMember> members) {
if (members.isEmpty) {
return _buildEmptyTip(context.l10n.settingsWorkMemoryEmptyTeamMembers);
}
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,
context.l10n.settingsUserMemoryFieldName,
_text(member.name),
),
_buildInfoRow(
Icons.person_pin_outlined,
context.l10n.settingsUserMemoryFieldRole,
_text(member.role),
),
_buildInfoRow(
Icons.account_tree_outlined,
context.l10n.settingsUserMemoryFieldRelationship,
_text(member.relationship),
),
_buildInfoRow(
Icons.phone_outlined,
context.l10n.settingsUserMemoryFieldContact,
_text(member.preferredContactChannel),
),
_buildInfoRow(
Icons.note_outlined,
context.l10n.settingsUserMemoryFieldNotes,
_text(member.notes),
),
],
),
);
}).toList(),
);
}
Widget _buildTags(List<String> tags) {
if (tags.isEmpty) {
return _buildEmptyTip(context.l10n.memoryNoInfo);
}
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 ? context.l10n.settingsUnset : raw;
}
String _listText(List<String> values) {
if (values.isEmpty) return context.l10n.settingsUnset;
return values.join('');
}
String _intListText(List<int> values, {required String suffix}) {
if (values.isEmpty) return context.l10n.settingsUnset;
return values.map((value) => '$value$suffix').join('');
}
String _timeWindowSummary(List<TimeWindow> windows) {
if (windows.isEmpty) return context.l10n.settingsUnset;
return context.l10n.settingsWorkMemoryTimeWindowCount(windows.length);
}
}
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/design_tokens.dart';
class AccountSectionCard extends StatelessWidget {
const AccountSectionCard({
super.key,
this.title,
this.description,
required this.child,
this.backgroundColor = AppColors.white,
this.borderColor = AppColors.borderSecondary,
this.contentPadding = const EdgeInsets.all(AppSpacing.lg),
});
final String? title;
final String? description;
final Widget child;
final Color backgroundColor;
final Color borderColor;
final EdgeInsetsGeometry contentPadding;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: contentPadding,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null) ...[
Text(
title!,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
),
],
if (description != null) ...[
const SizedBox(height: AppSpacing.xs),
Text(
description!,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
],
if (title != null || description != null)
const SizedBox(height: AppSpacing.lg),
child,
],
),
);
}
}
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/back_title_page_header.dart';
class SettingsPageScaffold extends StatelessWidget {
const SettingsPageScaffold({
super.key,
required this.title,
required this.body,
this.footer,
this.onBack,
this.trailing,
this.resizeOnKeyboard = true,
this.maintainBottomViewPadding = false,
});
final String title;
final Widget body;
final Widget? footer;
final VoidCallback? onBack;
final Widget? trailing;
final bool resizeOnKeyboard;
final bool maintainBottomViewPadding;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.surfaceSecondary,
resizeToAvoidBottomInset: resizeOnKeyboard,
body: SafeArea(
maintainBottomViewPadding: maintainBottomViewPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
BackTitlePageHeader(
title: title,
onBack: onBack,
trailing: trailing,
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.sm,
AppSpacing.xl,
AppSpacing.xl,
),
child: body,
),
),
if (footer != null)
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.none,
AppSpacing.xl,
AppSpacing.xl,
),
child: footer,
),
],
),
),
);
}
}