This commit is contained in:
2026-08-08 10:57:54 +08:00
parent d0d48c1ffc
commit 604af909b0
184 changed files with 12521 additions and 12722 deletions
@@ -0,0 +1,80 @@
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../../core/constants/app_defaults.dart';
import '../../../core/providers/app_providers.dart';
import '../../../data/local/models/models.dart';
final profileProvider =
AsyncNotifierProvider<ProfileNotifier, UserProfile>(ProfileNotifier.new);
class ProfileNotifier extends AsyncNotifier<UserProfile> {
@override
Future<UserProfile> build() async {
final isar = await ref.watch(isarProvider.future);
final profile = await isar.userProfiles.get(AppDefaults.singletonId);
if (profile != null) return profile;
// Bootstrap should have created one; create defensively.
final fresh = UserProfile.createDefault();
await isar.writeTxn(() => isar.userProfiles.put(fresh));
return fresh;
}
Future<void> updateNickname(String nickname) async {
final trimmed = nickname.trim();
if (trimmed.isEmpty) return;
final current = state.value;
if (current == null) return;
final updated = current.copyWith(nickname: trimmed);
final isar = await ref.read(isarProvider.future);
await isar.writeTxn(() => isar.userProfiles.put(updated));
state = AsyncData(updated);
}
/// Picks an image from gallery or camera, copies it into the app sandbox
/// and stores the local file path on the profile.
Future<bool> updateAvatar({required bool fromCamera}) async {
final current = state.value;
if (current == null) return false;
final picker = ImagePicker();
final xfile = await picker.pickImage(
source: fromCamera ? ImageSource.camera : ImageSource.gallery,
maxWidth: 512,
maxHeight: 512,
imageQuality: 85,
);
if (xfile == null) return false;
final docs = await getApplicationDocumentsDirectory();
final destPath = p.join(docs.path, AppDefaults.avatarFileName);
final sourceFile = File(xfile.path);
await sourceFile.copy(destPath);
final updated = current.copyWith(
avatarLocalPath: destPath,
avatarSource: AvatarSource.localFile,
);
final isar = await ref.read(isarProvider.future);
await isar.writeTxn(() => isar.userProfiles.put(updated));
state = AsyncData(updated);
return true;
}
Future<void> resetAvatar() async {
final current = state.value;
if (current == null) return;
final updated = current.copyWith(
avatarAssetPath: AppDefaults.defaultAvatarAssetPath,
avatarLocalPath: null,
avatarSource: AvatarSource.asset,
);
final isar = await ref.read(isarProvider.future);
await isar.writeTxn(() => isar.userProfiles.put(updated));
state = AsyncData(updated);
}
}
@@ -0,0 +1,169 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:arcx/app/app_theme.dart';
import 'package:arcx/core/constants/app_defaults.dart';
import 'package:arcx/data/local/models/models.dart';
import 'package:arcx/features/profile/application/profile_controller.dart';
class ProfileEditDialog extends ConsumerStatefulWidget {
const ProfileEditDialog({super.key});
@override
ConsumerState<ProfileEditDialog> createState() => _ProfileEditDialogState();
}
class _ProfileEditDialogState extends ConsumerState<ProfileEditDialog> {
late final TextEditingController _controller;
bool _saving = false;
@override
void initState() {
super.initState();
final profile = ref.read(profileProvider).value;
_controller = TextEditingController(text: profile?.nickname ?? '');
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final profile = ref.watch(profileProvider).value;
return AlertDialog(
title: const Text('编辑个人资料'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: _saving ? null : () => _pickAvatar(context),
child: _AvatarPreview(profile: profile),
),
if (profile?.avatarSource == AvatarSource.localFile)
TextButton.icon(
onPressed: _saving ? null : _resetAvatar,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('恢复默认头像'),
),
const SizedBox(height: 8),
TextField(
controller: _controller,
maxLength: 12,
decoration: const InputDecoration(
labelText: '昵称',
hintText: '1~12 个字符',
counterText: '',
),
),
],
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.pop(context),
child: const Text('取消'),
),
FilledButton(
onPressed: _saving ? null : _save,
child: const Text('保存'),
),
],
);
}
Future<void> _pickAvatar(BuildContext context) async {
final choice = await showModalBottomSheet<AvatarAction>(
context: context,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_outlined),
title: const Text('从相册选择'),
onTap: () => Navigator.pop(context, AvatarAction.gallery),
),
ListTile(
leading: const Icon(Icons.camera_alt_outlined),
title: const Text('拍摄照片'),
onTap: () => Navigator.pop(context, AvatarAction.camera),
),
],
),
),
);
if (choice == null) return;
setState(() => _saving = true);
await ref
.read(profileProvider.notifier)
.updateAvatar(fromCamera: choice == AvatarAction.camera);
if (mounted) setState(() => _saving = false);
}
Future<void> _resetAvatar() async {
setState(() => _saving = true);
await ref.read(profileProvider.notifier).resetAvatar();
if (mounted) setState(() => _saving = false);
}
Future<void> _save() async {
final name = _controller.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('昵称不能为空')));
return;
}
setState(() => _saving = true);
await ref.read(profileProvider.notifier).updateNickname(name);
if (mounted) {
setState(() => _saving = false);
Navigator.pop(context);
}
}
}
enum AvatarAction { gallery, camera }
class _AvatarPreview extends StatelessWidget {
const _AvatarPreview({required this.profile});
final UserProfile? profile;
@override
Widget build(BuildContext context) {
final p = profile;
final ImageProvider image;
if (p != null &&
p.avatarSource == AvatarSource.localFile &&
p.avatarLocalPath != null &&
p.avatarLocalPath!.isNotEmpty) {
image = FileImage(File(p.avatarLocalPath!));
} else {
image = const AssetImage(AppDefaults.defaultAvatarAssetPath);
}
return Stack(
alignment: Alignment.bottomRight,
children: [
CircleAvatar(
radius: 44,
backgroundColor: AppTheme.surfaceMuted,
backgroundImage: image,
),
Container(
padding: const EdgeInsets.all(6),
decoration: const BoxDecoration(
color: AppTheme.primary,
shape: BoxShape.circle,
),
child: const Icon(Icons.edit, color: Colors.white, size: 16),
),
],
);
}
}