init
This commit is contained in:
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user