feat(apps): 重构 UI 架构为 presentation 层并新增 l10n 国际化支持
This commit is contained in:
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user