402 lines
12 KiB
Dart
402 lines
12 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_lucide/flutter_lucide.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:photo_manager/photo_manager.dart';
|
|
|
|
import '../../../core/theme/app_colors.dart';
|
|
import '../../../core/theme/app_spacing.dart';
|
|
|
|
/// 底部弹层:首格为拍照,其余为相册图片(与常见 App 一致)
|
|
class AlbumPickerSheet extends StatefulWidget {
|
|
const AlbumPickerSheet({super.key});
|
|
|
|
@override
|
|
State<AlbumPickerSheet> createState() => _AlbumPickerSheetState();
|
|
}
|
|
|
|
class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
|
|
/// 首屏少一点,减轻同时解码缩略图的压力
|
|
static const int _pageSize = 36;
|
|
|
|
final ScrollController _scrollController = ScrollController();
|
|
|
|
bool _busy = true;
|
|
String? _error;
|
|
AssetPathEntity? _recentAlbum;
|
|
final List<AssetEntity> _assets = [];
|
|
int _loadedPage = -1;
|
|
int _totalCount = 0;
|
|
bool _loadingMore = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_scrollController.addListener(_maybeLoadMore);
|
|
// 等弹层完成首帧布局后再跑权限/读库,避免与 ModalRoute 争主线程;也方便 ScrollController 晚一点 attach
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _init();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController.removeListener(_maybeLoadMore);
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _init() async {
|
|
setState(() {
|
|
_busy = true;
|
|
_error = null;
|
|
});
|
|
|
|
final state = await PhotoManager.requestPermissionExtend();
|
|
if (!mounted) return;
|
|
|
|
if (!state.hasAccess) {
|
|
setState(() {
|
|
_busy = false;
|
|
_error = state == PermissionState.denied
|
|
? 'Photo library access is required. Tap below to open Settings.'
|
|
: 'Cannot access photo library.';
|
|
});
|
|
return;
|
|
}
|
|
|
|
final paths = await PhotoManager.getAssetPathList(
|
|
type: RequestType.image,
|
|
onlyAll: true,
|
|
hasAll: true,
|
|
);
|
|
if (!mounted) return;
|
|
|
|
if (paths.isEmpty) {
|
|
setState(() {
|
|
_busy = false;
|
|
_recentAlbum = null;
|
|
_totalCount = 0;
|
|
});
|
|
return;
|
|
}
|
|
|
|
_recentAlbum = paths.first;
|
|
_totalCount = await _recentAlbum!.assetCountAsync;
|
|
final first = await _recentAlbum!.getAssetListPaged(page: 0, size: _pageSize);
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_assets
|
|
..clear()
|
|
..addAll(first);
|
|
_loadedPage = 0;
|
|
_busy = false;
|
|
});
|
|
}
|
|
|
|
void _maybeLoadMore() {
|
|
if (_loadingMore || _busy || _recentAlbum == null) return;
|
|
if (_assets.length >= _totalCount) return;
|
|
// 未 attach 时访问 .position 会抛错,导致点击 Generate 后卡死/闪退
|
|
if (!_scrollController.hasClients) return;
|
|
final pos = _scrollController.position;
|
|
final maxExtent = pos.maxScrollExtent;
|
|
if (!maxExtent.isFinite || maxExtent <= 0) return;
|
|
if (pos.pixels < maxExtent - 380) return;
|
|
_loadMore();
|
|
}
|
|
|
|
Future<void> _loadMore() async {
|
|
final album = _recentAlbum;
|
|
if (album == null || _loadingMore) return;
|
|
if (_assets.length >= _totalCount) return;
|
|
|
|
setState(() => _loadingMore = true);
|
|
try {
|
|
final nextPage = _loadedPage + 1;
|
|
final list =
|
|
await album.getAssetListPaged(page: nextPage, size: _pageSize);
|
|
if (!mounted) return;
|
|
if (list.isNotEmpty) {
|
|
setState(() {
|
|
_assets.addAll(list);
|
|
_loadedPage = nextPage;
|
|
});
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _loadingMore = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _onCamera() async {
|
|
final picker = ImagePicker();
|
|
final x = await picker.pickImage(
|
|
source: ImageSource.camera,
|
|
imageQuality: 85,
|
|
);
|
|
if (!mounted) return;
|
|
if (x != null) {
|
|
Navigator.of(context).pop<String>(x.path);
|
|
}
|
|
}
|
|
|
|
Future<void> _onAsset(AssetEntity e) async {
|
|
final f = await e.file;
|
|
if (!mounted) return;
|
|
if (f != null) {
|
|
Navigator.of(context).pop<String>(f.path);
|
|
} else if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Could not open this photo. Try another.'),
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
static const int _crossCount = 3;
|
|
static const double _spacing = 3;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final bottomPad = MediaQuery.paddingOf(context).bottom;
|
|
|
|
return Material(
|
|
color: AppColors.surface,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
AppSpacing.sm,
|
|
AppSpacing.sm,
|
|
AppSpacing.sm,
|
|
AppSpacing.md,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
onPressed: () => Navigator.of(context).pop<String?>(),
|
|
icon: const Icon(LucideIcons.x, color: AppColors.textPrimary),
|
|
),
|
|
const Expanded(
|
|
child: Text(
|
|
'Choose photo',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 48),
|
|
],
|
|
),
|
|
),
|
|
const Divider(height: 1, color: AppColors.border),
|
|
Expanded(
|
|
child: _busy
|
|
? const Center(
|
|
child: CircularProgressIndicator(color: AppColors.primary),
|
|
)
|
|
: _error != null
|
|
? Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(AppSpacing.xl),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
_error!,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
TextButton(
|
|
onPressed: () async {
|
|
await PhotoManager.openSetting();
|
|
},
|
|
child: const Text('Open Settings'),
|
|
),
|
|
TextButton(
|
|
onPressed: _init,
|
|
child: const Text('Try again'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
: GridView.builder(
|
|
controller: _scrollController,
|
|
cacheExtent: 220,
|
|
padding: EdgeInsets.fromLTRB(
|
|
_spacing,
|
|
_spacing,
|
|
_spacing,
|
|
_spacing + bottomPad,
|
|
),
|
|
gridDelegate:
|
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: _crossCount,
|
|
mainAxisSpacing: _spacing,
|
|
crossAxisSpacing: _spacing,
|
|
childAspectRatio: 1,
|
|
),
|
|
itemCount:
|
|
1 + _assets.length + (_loadingMore ? 1 : 0),
|
|
itemBuilder: (context, index) {
|
|
if (index == 0) {
|
|
return _CameraGridTile(onTap: _onCamera);
|
|
}
|
|
final ai = index - 1;
|
|
if (ai >= _assets.length) {
|
|
return const Center(
|
|
child: SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: AppColors.primary,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
final asset = _assets[ai];
|
|
return GestureDetector(
|
|
onTap: () => _onAsset(asset),
|
|
child: _AlbumThumbnail(asset: asset),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CameraGridTile extends StatelessWidget {
|
|
const _CameraGridTile({required this.onTap});
|
|
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: AppColors.surfaceAlt,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
LucideIcons.camera,
|
|
size: 32,
|
|
color: AppColors.primary.withValues(alpha: 0.9),
|
|
),
|
|
const SizedBox(height: AppSpacing.sm),
|
|
Text(
|
|
'Camera',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textSecondary.withValues(alpha: 0.95),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AlbumThumbnail extends StatefulWidget {
|
|
const _AlbumThumbnail({required this.asset});
|
|
|
|
final AssetEntity asset;
|
|
|
|
@override
|
|
State<_AlbumThumbnail> createState() => _AlbumThumbnailState();
|
|
}
|
|
|
|
class _AlbumThumbnailState extends State<_AlbumThumbnail> {
|
|
Uint8List? _bytes;
|
|
bool _loadFailed = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_load();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant _AlbumThumbnail oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.asset.id != widget.asset.id) {
|
|
_bytes = null;
|
|
_loadFailed = false;
|
|
_load();
|
|
}
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
try {
|
|
final data = await widget.asset.thumbnailDataWithSize(
|
|
const ThumbnailSize.square(200),
|
|
);
|
|
if (mounted) {
|
|
setState(() {
|
|
_bytes = data;
|
|
_loadFailed = data == null || data.isEmpty;
|
|
});
|
|
}
|
|
} catch (_) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_bytes = null;
|
|
_loadFailed = true;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final b = _bytes;
|
|
return ColoredBox(
|
|
color: AppColors.surfaceAlt,
|
|
child: b != null && b.isNotEmpty
|
|
? Image.memory(
|
|
b,
|
|
fit: BoxFit.cover,
|
|
gaplessPlayback: true,
|
|
filterQuality: FilterQuality.low,
|
|
)
|
|
: _loadFailed
|
|
? const Icon(
|
|
LucideIcons.image_off,
|
|
size: 28,
|
|
color: AppColors.textSecondary,
|
|
)
|
|
: const Center(
|
|
child: SizedBox(
|
|
width: 22,
|
|
height: 22,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: AppColors.primary,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|