fix:增加图片选择和拍照功能

This commit is contained in:
jingyun
2026-06-23 10:07:35 +08:00
parent 75ba309e8c
commit 8cf919283b
16 changed files with 1288 additions and 45 deletions
@@ -43,6 +43,8 @@ class DeviceHeaderService {
String _androidOaid = '';
String _androidImei = '';
String _androidMac = '';
IosDeviceInfo? iosDeviceInfo;
AndroidDeviceInfo? androidDeviceInfo;
Future<void>? _baseInitialization;
Future<void>? _identityInitialization;
@@ -120,9 +122,23 @@ class DeviceHeaderService {
_iosIdfv = await _resolveIdfv();
await _collectIosPaidIfMissing();
}
await getDeviceInfo();
} catch (_) {}
}
Future<void> getDeviceInfo() async {
try {
final deviceInfoPlugin = DeviceInfoPlugin();
if (Platform.isIOS) {
iosDeviceInfo = await deviceInfoPlugin.iosInfo;
} else if (Platform.isAndroid) {
androidDeviceInfo = await deviceInfoPlugin.androidInfo;
}
} catch (e) {
print('$e');
}
}
Future<void> _collectAndroidIdIfAgreed() async {
if (_androidId.isNotEmpty) return;
if (!StorageService.to.getBool(storagePrivacyAgreed)) return; // 隐私同意后才采集
+20
View File
@@ -0,0 +1,20 @@
import '../../features/assets/data/asset_models.dart';
class UploadRepository {
const UploadRepository();
Future<UploadResult> uploadAssetImage(AssetImage image) async {
// TODO: Connect to upload backend.
return UploadResult(
remoteUrl: image.remoteUrl,
thumbnailUrl: image.thumbnailUrl,
);
}
}
class UploadResult {
const UploadResult({this.remoteUrl, this.thumbnailUrl});
final String? remoteUrl;
final String? thumbnailUrl;
}
+13
View File
@@ -0,0 +1,13 @@
import '../../features/assets/data/asset_models.dart';
import 'upload_repository.dart';
class UploadService {
const UploadService({required this.repository});
final UploadRepository repository;
Future<UploadResult> uploadAssetImage(AssetImage image) async {
// Placeholder: future implementation will upload the file and return URLs.
return repository.uploadAssetImage(image);
}
}
+119
View File
@@ -0,0 +1,119 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:gal/gal.dart';
import 'package:http/http.dart' as http;
import 'package:jinzhi_flutter_app/common/services/device_header_service.dart';
import 'package:permission_handler/permission_handler.dart';
class ImageUtil {
static const String _albumName = 'jinzhi';
static Stream<FileResponse> downloadImage(String url, String key) {
return DefaultCacheManager().getFileStream(
url,
key: key,
withProgress: true,
);
}
static Future<String> downloadImageInFile(String url, String key) async {
final file = await DefaultCacheManager().getSingleFile(url, key: key);
return file.path;
}
static Future<bool> requestForImagePermission() async {
if (Platform.isIOS) {
PermissionStatus photoStatus = await Permission.photosAddOnly.status;
if (photoStatus != PermissionStatus.granted) {
photoStatus = await Permission.photosAddOnly.request();
if (photoStatus != PermissionStatus.granted) {
return false;
}
}
} else if (Platform.isAndroid) {
int? sdkInt = DeviceHeaderService.to.androidDeviceInfo?.version.sdkInt;
if (sdkInt != null && sdkInt >= 33) {
// ✅ Android 13+ 不需要读权限,直接返回 true
// gal 插件内部通过 MediaStore 写入
return true;
} else {
// ✅ Android 10-12 仍可能需要 storage 权限
final status = await Permission.storage.request();
return status.isGranted;
}
} else {
return false;
}
return true;
}
static Future<void> saveImage(FileInfo fileInfo) async {
await Gal.putImage(fileInfo.file.path, album: _albumName);
}
static Future<void> saveImageData(ByteData byteData) async {
Uint8List imageBytes = byteData.buffer.asUint8List();
await Gal.putImageBytes(
imageBytes,
name: 'XWallpaper_share',
album: _albumName,
);
}
static Future<void> saveImageUint8List(Uint8List imageBytes) async {
await Gal.putImageBytes(
imageBytes,
name: 'XWallpaper_share',
album: _albumName,
);
}
static Future<void> saveUIImageData(ui.Image image) async {
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
Uint8List imageBytes = byteData!.buffer.asUint8List();
await Gal.putImageBytes(
imageBytes,
name: 'XWallpaper_share',
album: _albumName,
);
}
static Future<Uint8List> createTransparentImage(double aspectRatio) async {
double minSize = 100;
double width = minSize * aspectRatio;
double height = minSize;
final recorder = PictureRecorder();
final canvas = Canvas(
recorder,
Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()),
);
final paint = Paint()..color = Colors.transparent;
canvas.drawRect(
Rect.fromLTWH(0.0, 0.0, width.toDouble(), height.toDouble()),
paint,
);
final picture = recorder.endRecording();
final img = await picture.toImage(width.toInt(), height.toInt());
final pngBytes = await img.toByteData(format: ImageByteFormat.png);
return pngBytes!.buffer.asUint8List();
}
// 下载图片并返回 Uint8List(简单实现)
static Future<Uint8List?> fetchThumbBytes(String? url) async {
if (url == null || url.isEmpty) return null;
try {
final resp = await http.get(Uri.parse(url));
if (resp.statusCode == 200) return resp.bodyBytes;
} catch (_) {}
return null;
}
}
+163
View File
@@ -1,5 +1,58 @@
import '../../../common/domain/metal_type.dart';
class AssetImage {
const AssetImage({
required this.localId,
required this.localPath,
required this.createdAt,
this.remoteUrl,
this.thumbnailUrl,
});
final String localId;
final String localPath;
final String? remoteUrl;
final String? thumbnailUrl;
final DateTime createdAt;
AssetImage copyWith({
String? localId,
String? localPath,
String? remoteUrl,
String? thumbnailUrl,
DateTime? createdAt,
}) {
return AssetImage(
localId: localId ?? this.localId,
localPath: localPath ?? this.localPath,
remoteUrl: remoteUrl ?? this.remoteUrl,
thumbnailUrl: thumbnailUrl ?? this.thumbnailUrl,
createdAt: createdAt ?? this.createdAt,
);
}
Map<String, dynamic> toJson() {
return {
'localId': localId,
'localPath': localPath,
'remoteUrl': remoteUrl,
'thumbnailUrl': thumbnailUrl,
'createdAt': createdAt.toIso8601String(),
};
}
factory AssetImage.fromJson(Map<String, dynamic> json) {
return AssetImage(
localId: json['localId'] as String? ?? '',
localPath: json['localPath'] as String? ?? '',
remoteUrl: json['remoteUrl'] as String?,
thumbnailUrl: json['thumbnailUrl'] as String?,
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
);
}
}
enum AssetCategory {
bar('金条'),
necklace('项链'),
@@ -28,6 +81,7 @@ class GoldAssetHolding {
required this.weightGram,
required this.createdAt,
required this.updatedAt,
this.images = const [],
this.costAmount,
this.purchaseDate,
this.channel,
@@ -42,6 +96,7 @@ class GoldAssetHolding {
final double purity;
final String purityLabel;
final double weightGram;
final List<AssetImage> images;
final double? costAmount;
final DateTime? purchaseDate;
final String? channel;
@@ -50,7 +105,92 @@ class GoldAssetHolding {
final DateTime updatedAt;
final HoldingStatus status;
GoldAssetHolding copyWith({
String? id,
String? name,
MetalType? metal,
AssetCategory? category,
double? purity,
String? purityLabel,
double? weightGram,
List<AssetImage>? images,
double? costAmount,
DateTime? purchaseDate,
String? channel,
String? note,
DateTime? createdAt,
DateTime? updatedAt,
HoldingStatus? status,
}) {
return GoldAssetHolding(
id: id ?? this.id,
name: name ?? this.name,
metal: metal ?? this.metal,
category: category ?? this.category,
purity: purity ?? this.purity,
purityLabel: purityLabel ?? this.purityLabel,
weightGram: weightGram ?? this.weightGram,
images: images ?? this.images,
costAmount: costAmount ?? this.costAmount,
purchaseDate: purchaseDate ?? this.purchaseDate,
channel: channel ?? this.channel,
note: note ?? this.note,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
status: status ?? this.status,
);
}
double materialValue(double spotPrice) => weightGram * purity * spotPrice;
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'metalId': metal.id,
'categoryId': category.name,
'purity': purity,
'purityLabel': purityLabel,
'weightGram': weightGram,
'images': images.map((item) => item.toJson()).toList(growable: false),
'costAmount': costAmount,
'purchaseDate': purchaseDate?.toIso8601String(),
'channel': channel,
'note': note,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
'status': status.name,
};
}
factory GoldAssetHolding.fromJson(Map<String, dynamic> json) {
final rawImages = json['images'];
return GoldAssetHolding(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
metal: _metalFromId(json['metalId'] as String?),
category: _categoryFromId(json['categoryId'] as String?),
purity: (json['purity'] as num?)?.toDouble() ?? 1,
purityLabel: json['purityLabel'] as String? ?? '',
weightGram: (json['weightGram'] as num?)?.toDouble() ?? 0,
images: rawImages is List
? [
for (final raw in rawImages)
if (raw is Map)
AssetImage.fromJson(Map<String, dynamic>.from(raw)),
]
: const [],
costAmount: (json['costAmount'] as num?)?.toDouble(),
purchaseDate: DateTime.tryParse(json['purchaseDate'] as String? ?? ''),
channel: json['channel'] as String?,
note: json['note'] as String?,
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
status: _statusFromName(json['status'] as String?),
);
}
}
class HoldingValuation {
@@ -100,3 +240,26 @@ class PortfolioSummary {
final List<MetalBreakdown> breakdowns;
final List<HoldingValuation> holdings;
}
MetalType _metalFromId(String? id) {
return switch (id) {
'gold' => MetalType.gold,
'platinum' => MetalType.platinum,
'silver' => MetalType.silver,
_ => MetalType.gold,
};
}
AssetCategory _categoryFromId(String? id) {
return AssetCategory.values.firstWhere(
(item) => item.name == id,
orElse: () => AssetCategory.bar,
);
}
HoldingStatus _statusFromName(String? value) {
return HoldingStatus.values.firstWhere(
(item) => item.name == value,
orElse: () => HoldingStatus.active,
);
}
@@ -1,6 +1,13 @@
import 'package:flutter/material.dart';
import 'dart:io';
import 'package:flutter/material.dart' hide AssetImage;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:uuid/uuid.dart';
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
import '../../../common/domain/metal_type.dart';
import '../../../common/domain/money.dart';
@@ -43,6 +50,16 @@ class _AssetAddPageState extends ConsumerState<AssetAddPage> {
final _noteCtrl = TextEditingController();
final _dateCtrl = TextEditingController(text: '2025-08-12');
final _channelCtrl = TextEditingController(text: '银行');
final _picker = ImagePicker();
final _images = <AssetImage>[];
final _uuid = const Uuid();
static const _maxImages = 9;
int get _remainingImageSlots => _maxImages - _images.length;
bool get _isImageLimitReached => _remainingImageSlots <= 0;
String get _imageLimitText => '最多可添加 $_maxImages 张图片';
@override
void dispose() {
@@ -91,6 +108,345 @@ class _AssetAddPageState extends ConsumerState<AssetAddPage> {
};
}
Future<void> pickDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime.now(),
locale: const Locale('zh'),
);
if (picked != null) {
print(picked.toString());
}
}
Future<void> _showImageSourceSheet() async {
if (!mounted) return;
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
width: 38,
height: 4,
decoration: BoxDecoration(
color: const Color(0xFFE3DFD4),
borderRadius: BorderRadius.circular(999),
),
),
const SizedBox(height: 14),
ListTile(
leading: const Icon(Icons.camera_alt_outlined),
title: const Text('拍照'),
onTap: () {
Navigator.of(ctx).pop();
_handleTakePhoto();
},
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('从相册选择'),
onTap: () {
Navigator.of(ctx).pop();
_handlePickFromGallery();
},
),
const SizedBox(height: 8),
ListTile(
title: const Text(
'取消',
textAlign: TextAlign.center,
style: TextStyle(color: Color(0xFF8A8780)),
),
onTap: () => Navigator.of(ctx).pop(),
),
],
),
),
);
}
Future<void> _handleTakePhoto() async {
final granted = await _requestCameraPermission();
if (!granted) {
_showPermissionDeniedDialog('相机');
return;
}
final file = await _picker.pickImage(
source: ImageSource.camera,
imageQuality: 80,
maxWidth: 2048,
);
if (file == null) return;
await _addPickedFile(file);
}
Future<void> _handlePickFromGallery() async {
final availableSlots = _remainingImageSlots;
if (availableSlots <= 0) return;
final assets = await AssetPicker.pickAssets(
context,
pickerConfig: AssetPickerConfig(
maxAssets: availableSlots,
requestType: RequestType.image,
themeColor: const Color(0xFFB5862A),
),
);
if (assets == null || assets.isEmpty) {
return;
}
final pickedImages = <AssetImage>[];
for (final asset in assets.take(availableSlots)) {
final file = await asset.file;
if (file == null) continue;
pickedImages.add(await _assetImageFromSourceFile(file));
}
if (pickedImages.isEmpty || !mounted) return;
setState(() {
_images.addAll(pickedImages);
});
}
Future<AssetImage> _assetImageFromXFile(XFile file) {
return _assetImageFromSourceFile(File(file.path));
}
Future<AssetImage> _assetImageFromSourceFile(File sourceFile) async {
final localId =
'img${DateTime.now().microsecondsSinceEpoch}${_uuid.v4().split('-').first}';
final extension = _extensionForPath(sourceFile.path);
final directory = await getApplicationSupportDirectory();
final imageDirectory = Directory('${directory.path}/asset_images');
if (!await imageDirectory.exists()) {
await imageDirectory.create(recursive: true);
}
final copiedFile = await sourceFile.copy(
'${imageDirectory.path}/$localId$extension',
);
return AssetImage(
localId: localId,
localPath: copiedFile.path,
createdAt: DateTime.now(),
);
}
String _extensionForPath(String path) {
final fileName = path.split(Platform.pathSeparator).last;
final dotIndex = fileName.lastIndexOf('.');
if (dotIndex <= 0 || dotIndex == fileName.length - 1) {
return '.jpg';
}
return fileName.substring(dotIndex);
}
Future<bool> _requestCameraPermission() async {
final status = await Permission.camera.status;
if (status.isGranted) return true;
final result = await Permission.camera.request();
return result.isGranted;
}
void _showPermissionDeniedDialog(String target) {
if (!mounted) return;
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('权限不足'),
content: Text('无法访问$target,请在系统设置中开启权限。'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
openAppSettings();
},
child: const Text('去设置'),
),
],
),
);
}
Future<void> _addPickedFile(XFile file) async {
if (_images.length >= _maxImages) return;
final image = await _assetImageFromXFile(file);
if (!mounted) return;
setState(() {
_images.add(image);
});
}
void _removeImage(String localId) {
setState(() {
_images.removeWhere((item) => item.localId == localId);
});
}
Widget _buildImagesSection() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFECEAE3), width: 0.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_images.isEmpty) ...[
GestureDetector(
onTap: _showImageSourceSheet,
child: Row(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(11),
border: Border.all(
color: const Color(0xFFD3CDBE),
width: 0.5,
),
),
child: const Icon(
Icons.camera_alt_outlined,
color: Color(0xFFB3AFA5),
),
),
const SizedBox(width: 10),
const Expanded(
child: Text(
'添加发票 / 实物照片(选填,仅存本机)',
style: TextStyle(fontSize: 12, color: Color(0xFFB3AFA5)),
),
),
],
),
),
const SizedBox(height: 8),
const Text(
'支持发票、收据、金条证书、实物照片。',
style: TextStyle(fontSize: 12, color: Color(0xFFB3AFA5)),
),
] else ...[
const Text(
'凭证照片',
style: TextStyle(fontSize: 13, color: Color(0xFF8A8780)),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final image in _images) _buildImageTile(image),
if (!_isImageLimitReached)
GestureDetector(
onTap: _showImageSourceSheet,
child: Container(
width: 84,
height: 84,
decoration: BoxDecoration(
color: const Color(0xFFF6ECD6),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Icon(Icons.add, color: Color(0xFFB5862A)),
SizedBox(height: 5),
Text(
'继续补充',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: Color(0xFFB5862A),
),
),
],
),
),
),
if (_isImageLimitReached)
Container(
width: 84,
height: 84,
decoration: BoxDecoration(
color: const Color(0xFFF2F0EA),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFFE3DFD4),
width: 0.5,
),
),
alignment: Alignment.center,
child: const Text(
'已满 9 张',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 11, color: Color(0xFF8A8780)),
),
),
],
),
const SizedBox(height: 10),
Text(
_imageLimitText,
style: const TextStyle(fontSize: 12, color: Color(0xFFB3AFA5)),
),
],
],
),
);
}
Widget _buildImageTile(AssetImage image) {
return Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
File(image.localPath),
width: 84,
height: 84,
fit: BoxFit.cover,
),
),
Positioned(
top: 6,
right: 6,
child: GestureDetector(
onTap: () => _removeImage(image.localId),
child: Container(
width: 22,
height: 22,
decoration: const BoxDecoration(
color: Color(0xAA000000),
shape: BoxShape.circle,
),
child: const Icon(Icons.close, size: 14, color: Colors.white),
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
@@ -404,48 +760,9 @@ class _AssetAddPageState extends ConsumerState<AssetAddPage> {
),
),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFFECEAE3),
width: 0.5,
),
),
child: Row(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(11),
border: Border.all(
color: const Color(0xFFD3CDBE),
width: 0.5,
),
),
child: const Icon(
Icons.camera_alt_outlined,
color: Color(0xFFB3AFA5),
),
),
const SizedBox(width: 10),
const Expanded(
child: Text(
'添加发票 / 实物照片(选填,仅存本机)',
style: TextStyle(
fontSize: 12,
color: Color(0xFFB3AFA5),
),
),
),
],
),
),
_buildImagesSection(),
const SizedBox(height: 16),
],
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton(
@@ -468,6 +785,7 @@ class _AssetAddPageState extends ConsumerState<AssetAddPage> {
note: _noteCtrl.text.trim().isEmpty
? null
: _noteCtrl.text.trim(),
images: List<AssetImage>.unmodifiable(_images),
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
@@ -277,6 +277,7 @@ class _AssetEditPageState extends ConsumerState<AssetEditPage> {
purity: _holding.purity,
purityLabel: _holding.purityLabel,
weightGram: grams ?? _holding.weightGram,
images: _holding.images,
costAmount: cost,
purchaseDate: parsedDate,
channel: _channelCtrl.text.trim().isEmpty
@@ -0,0 +1,95 @@
import 'dart:io';
import 'package:flutter/material.dart' hide AssetImage;
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
import '../data/asset_models.dart';
class AssetImagePreviewPage extends StatefulWidget {
const AssetImagePreviewPage({
super.key,
required this.images,
required this.initialIndex,
});
final List<AssetImage> images;
final int initialIndex;
@override
State<AssetImagePreviewPage> createState() => _AssetImagePreviewPageState();
}
class _AssetImagePreviewPageState extends State<AssetImagePreviewPage> {
late final PageController _pageController;
late int _currentIndex;
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex.clamp(0, widget.images.length - 1);
_pageController = PageController(initialPage: _currentIndex);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Stack(
children: [
PhotoViewGallery.builder(
pageController: _pageController,
itemCount: widget.images.length,
builder: (context, index) {
final image = widget.images[index];
return PhotoViewGalleryPageOptions(
imageProvider: FileImage(File(image.localPath)),
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 2.2,
);
},
onPageChanged: (index) => setState(() {
_currentIndex = index;
}),
backgroundDecoration: const BoxDecoration(color: Colors.black),
),
Positioned(
top: 16,
left: 16,
child: IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, color: Colors.white),
),
),
Positioned(
bottom: 24,
left: 0,
right: 0,
child: Center(
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'${_currentIndex + 1}/${widget.images.length}',
style: const TextStyle(color: Colors.white, fontSize: 14),
),
),
),
),
],
),
),
);
}
}
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'dart:io';
import 'package:flutter/material.dart' hide AssetImage;
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
@@ -10,6 +12,7 @@ import '../../profile/application/price_color_theme.dart';
import '../../profile/application/profile_settings_controller.dart';
import '../application/asset_portfolio_controller.dart';
import '../data/asset_models.dart';
import 'asset_image_preview_page.dart';
class HoldingDetailPage extends ConsumerWidget {
const HoldingDetailPage({super.key, this.id});
@@ -240,6 +243,8 @@ class HoldingDetailPage extends ConsumerWidget {
),
),
const SizedBox(height: 12),
_buildImageSection(context, ref, holding),
const SizedBox(height: 12),
Row(
children: [
Expanded(
@@ -362,6 +367,135 @@ class HoldingDetailPage extends ConsumerWidget {
).showSnackBar(const SnackBar(content: Text('已标记卖出 / 转赠')));
Navigator.of(context).pop();
}
Widget _buildImageSection(
BuildContext context,
WidgetRef ref,
GoldAssetHolding holding,
) {
final images = holding.images;
return PrototypeCard(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'凭证照片',
style: TextStyle(fontSize: 13, color: Color(0xFF8A8780)),
),
const SizedBox(height: 12),
if (images.isEmpty)
const Text(
'暂无凭证照片,可在添加资产时上传发票、证书或实物照片。',
style: TextStyle(fontSize: 12, color: Color(0xFFB3AFA5)),
)
else
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (var index = 0; index < images.length; index++)
_buildDetailImageTile(
context,
ref,
holding,
images[index],
index,
),
],
),
],
),
);
}
Widget _buildDetailImageTile(
BuildContext context,
WidgetRef ref,
GoldAssetHolding holding,
AssetImage image,
int index,
) {
return GestureDetector(
onTap: () => _openImagePreview(context, holding.images, index),
onLongPress: () => _confirmDeleteImage(context, ref, holding, image),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
children: [
Image.file(
File(image.localPath),
width: 84,
height: 84,
fit: BoxFit.cover,
),
Positioned(
right: 4,
top: 4,
child: Container(
width: 20,
height: 20,
decoration: const BoxDecoration(
color: Color(0xAA000000),
shape: BoxShape.circle,
),
child: const Icon(Icons.remove, size: 14, color: Colors.white),
),
),
],
),
),
);
}
void _openImagePreview(
BuildContext context,
List<AssetImage> images,
int initialIndex,
) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AssetImagePreviewPage(
images: images,
initialIndex: initialIndex,
),
),
);
}
void _confirmDeleteImage(
BuildContext context,
WidgetRef ref,
GoldAssetHolding holding,
AssetImage image,
) {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除照片'),
content: const Text('确认删除这张凭证照片吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
final updated = holding.copyWith(
images: List<AssetImage>.unmodifiable(
holding.images.where((item) => item.localId != image.localId).toList(),
),
updatedAt: DateTime.now(),
);
ref.read(assetPortfolioControllerProvider.notifier).updateHolding(updated);
},
child: const Text('删除'),
),
],
),
);
}
}
class _ActionMenuTile extends StatelessWidget {