合规:去除不满足要求的READ_MEDIA_IMAGES权限
This commit is contained in:
parent
b05d3c0b0e
commit
42d9cf3fd2
@ -2,7 +2,7 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
<!-- 选图走 image_picker / 系统照片选择器 (Photo Picker),不声明 READ_MEDIA_* -->
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
<!-- 与 camera_android_camerax 中 maxSdkVersion=28 合并冲突:沿用本应用上限 API 32 -->
|
<!-- 与 camera_android_camerax 中 maxSdkVersion=28 合并冲突:沿用本应用上限 API 32 -->
|
||||||
<uses-permission
|
<uses-permission
|
||||||
|
|||||||
@ -60,12 +60,6 @@
|
|||||||
@import permission_handler_apple;
|
@import permission_handler_apple;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if __has_include(<photo_manager/PhotoManagerPlugin.h>)
|
|
||||||
#import <photo_manager/PhotoManagerPlugin.h>
|
|
||||||
#else
|
|
||||||
@import photo_manager;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if __has_include(<screen_secure/ScreenSecurePlugin.h>)
|
#if __has_include(<screen_secure/ScreenSecurePlugin.h>)
|
||||||
#import <screen_secure/ScreenSecurePlugin.h>
|
#import <screen_secure/ScreenSecurePlugin.h>
|
||||||
#else
|
#else
|
||||||
@ -120,7 +114,6 @@
|
|||||||
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
||||||
[InAppPurchasePlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppPurchasePlugin"]];
|
[InAppPurchasePlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppPurchasePlugin"]];
|
||||||
[PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]];
|
[PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]];
|
||||||
[PhotoManagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PhotoManagerPlugin"]];
|
|
||||||
[ScreenSecurePlugin registerWithRegistrar:[registry registrarForPlugin:@"ScreenSecurePlugin"]];
|
[ScreenSecurePlugin registerWithRegistrar:[registry registrarForPlugin:@"ScreenSecurePlugin"]];
|
||||||
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
|
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
|
||||||
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
|
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
import '../../core/auth/auth_service.dart';
|
import '../../core/auth/auth_service.dart';
|
||||||
@ -104,13 +105,28 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
|||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
builder: (ctx) => SizedBox(
|
builder: (ctx) => Padding(
|
||||||
height: MediaQuery.sizeOf(ctx).height * 0.92,
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.viewInsetsOf(ctx).bottom,
|
||||||
|
),
|
||||||
child: const AlbumPickerSheet(),
|
child: const AlbumPickerSheet(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (path == kAlbumPickerRequestGallery) {
|
||||||
|
final picker = ImagePicker();
|
||||||
|
final x = await picker.pickImage(source: ImageSource.gallery);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (x != null && x.path.isNotEmpty) {
|
||||||
|
final f = File(x.path);
|
||||||
|
if (await f.exists()) {
|
||||||
|
await _runGenerationApi(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (path == kAlbumPickerRequestCamera) {
|
if (path == kAlbumPickerRequestCamera) {
|
||||||
// 应用内相机:推全屏页前暂停背景视频,返回后短暂延迟再恢复,降低 GPU 切换毛刺
|
// 应用内相机:推全屏页前暂停背景视频,返回后短暂延迟再恢复,降低 GPU 切换毛刺
|
||||||
setState(() => _suspendBackgroundVideo = true);
|
setState(() => _suspendBackgroundVideo = true);
|
||||||
|
|||||||
@ -1,10 +1,5 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart'
|
|
||||||
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
|
||||||
|
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_spacing.dart';
|
import '../../../core/theme/app_spacing.dart';
|
||||||
@ -12,194 +7,13 @@ import '../../../core/theme/app_spacing.dart';
|
|||||||
/// [showModalBottomSheet] 返回此值时由**外层页面**调系统相机,避免 BottomSheet 与相机 Activity 叠放导致返回后黑屏/卡死。
|
/// [showModalBottomSheet] 返回此值时由**外层页面**调系统相机,避免 BottomSheet 与相机 Activity 叠放导致返回后黑屏/卡死。
|
||||||
const String kAlbumPickerRequestCamera = '__album_picker_camera__';
|
const String kAlbumPickerRequestCamera = '__album_picker_camera__';
|
||||||
|
|
||||||
/// 底部弹层:首格为拍照,其余为相册图片(与常见 App 一致)
|
/// 由外层调用 [ImagePicker](系统照片选择器 / Photo Picker),避免在 Sheet 内嵌套系统选图界面。
|
||||||
class AlbumPickerSheet extends StatefulWidget {
|
const String kAlbumPickerRequestGallery = '__album_picker_gallery__';
|
||||||
|
|
||||||
|
/// 底部弹层:拍照 与 从相册选择(Android 使用系统照片选择器,不申请 READ_MEDIA_*)
|
||||||
|
class AlbumPickerSheet extends StatelessWidget {
|
||||||
const AlbumPickerSheet({super.key});
|
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;
|
|
||||||
/// 最近一次权限结果(用于「仅部分照片」等说明)
|
|
||||||
PermissionState _permissionState = PermissionState.notDetermined;
|
|
||||||
|
|
||||||
@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;
|
|
||||||
_permissionState = state;
|
|
||||||
|
|
||||||
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;
|
|
||||||
_assets.clear();
|
|
||||||
_loadedPage = -1;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_recentAlbum = paths.first;
|
|
||||||
_totalCount = await _recentAlbum!.assetCountAsync;
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
if (_totalCount == 0) {
|
|
||||||
setState(() {
|
|
||||||
_assets.clear();
|
|
||||||
_loadedPage = -1;
|
|
||||||
_busy = false;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final first = await _recentAlbum!.getAssetListPaged(page: 0, size: _pageSize);
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_assets
|
|
||||||
..clear()
|
|
||||||
..addAll(first);
|
|
||||||
_loadedPage = 0;
|
|
||||||
_busy = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// iOS:系统「补充照片」面板;Android 等:打开系统设置改权限
|
|
||||||
Future<void> _openManagePhotoAccess() async {
|
|
||||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) {
|
|
||||||
try {
|
|
||||||
await PhotoManager.presentLimited();
|
|
||||||
} catch (_) {
|
|
||||||
await PhotoManager.openSetting();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await PhotoManager.openSetting();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mounted) await _init();
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
if (!mounted) return;
|
|
||||||
// 不在弹层内调 pickImage:BottomSheet 与相机 Activity 叠加时,返回后 Surface 有概率无法恢复(全黑)。
|
|
||||||
Navigator.of(context).pop<String>(kAlbumPickerRequestCamera);
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
final limited = _permissionState == PermissionState.limited;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
limited
|
|
||||||
? 'Cannot read this photo. It may not be included in your '
|
|
||||||
'current selection. Use "Manage photo access" or choose '
|
|
||||||
'another image.'
|
|
||||||
: 'Could not open this photo. Try another.',
|
|
||||||
),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get _showNoPhotosHelp =>
|
|
||||||
!_busy && _error == null && _totalCount == 0;
|
|
||||||
|
|
||||||
static const int _crossCount = 3;
|
|
||||||
static const double _spacing = 3;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final bottomPad = MediaQuery.paddingOf(context).bottom;
|
final bottomPad = MediaQuery.paddingOf(context).bottom;
|
||||||
@ -210,6 +24,7 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
|
|||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
@ -240,93 +55,32 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1, color: AppColors.border),
|
const Divider(height: 1, color: AppColors.border),
|
||||||
Expanded(
|
Padding(
|
||||||
child: _busy
|
padding: EdgeInsets.fromLTRB(
|
||||||
? const Center(
|
AppSpacing.lg,
|
||||||
child: CircularProgressIndicator(color: AppColors.primary),
|
AppSpacing.lg,
|
||||||
)
|
AppSpacing.lg,
|
||||||
: _error != null
|
AppSpacing.xl + bottomPad,
|
||||||
? Center(
|
),
|
||||||
child: Padding(
|
child: Column(
|
||||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
children: [
|
||||||
child: Column(
|
_ActionCard(
|
||||||
mainAxisSize: MainAxisSize.min,
|
icon: LucideIcons.camera,
|
||||||
children: [
|
title: 'Take photo',
|
||||||
Text(
|
subtitle: 'Use camera',
|
||||||
_error!,
|
onTap: () => Navigator.of(context)
|
||||||
textAlign: TextAlign.center,
|
.pop<String>(kAlbumPickerRequestCamera),
|
||||||
style: const TextStyle(
|
),
|
||||||
color: AppColors.textSecondary,
|
const SizedBox(height: AppSpacing.md),
|
||||||
),
|
_ActionCard(
|
||||||
),
|
icon: LucideIcons.images,
|
||||||
const SizedBox(height: AppSpacing.lg),
|
title: 'Choose from gallery',
|
||||||
TextButton(
|
subtitle: 'System photo picker',
|
||||||
onPressed: () async {
|
onTap: () => Navigator.of(context)
|
||||||
await PhotoManager.openSetting();
|
.pop<String>(kAlbumPickerRequestGallery),
|
||||||
},
|
),
|
||||||
child: const Text('Open Settings'),
|
],
|
||||||
),
|
),
|
||||||
TextButton(
|
|
||||||
onPressed: _init,
|
|
||||||
child: const Text('Try again'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: _showNoPhotosHelp
|
|
||||||
? _NoPhotosInLibraryPanel(
|
|
||||||
isLimitedAccess:
|
|
||||||
_permissionState == PermissionState.limited,
|
|
||||||
onManageAccess: _openManagePhotoAccess,
|
|
||||||
onOpenSettings: () async {
|
|
||||||
await PhotoManager.openSetting();
|
|
||||||
},
|
|
||||||
onRetry: _init,
|
|
||||||
onUseCamera: _onCamera,
|
|
||||||
)
|
|
||||||
: 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),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -334,220 +88,77 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 权限为「部分照片」或相册对本应用不可见时:引导补充选图或去设置,避免只显示相机无说明
|
class _ActionCard extends StatelessWidget {
|
||||||
class _NoPhotosInLibraryPanel extends StatelessWidget {
|
const _ActionCard({
|
||||||
const _NoPhotosInLibraryPanel({
|
required this.icon,
|
||||||
required this.isLimitedAccess,
|
required this.title,
|
||||||
required this.onManageAccess,
|
required this.subtitle,
|
||||||
required this.onOpenSettings,
|
required this.onTap,
|
||||||
required this.onRetry,
|
|
||||||
required this.onUseCamera,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool isLimitedAccess;
|
final IconData icon;
|
||||||
final Future<void> Function() onManageAccess;
|
final String title;
|
||||||
final Future<void> Function() onOpenSettings;
|
final String subtitle;
|
||||||
final Future<void> Function() onRetry;
|
|
||||||
final Future<void> Function() onUseCamera;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final bottom = MediaQuery.paddingOf(context).bottom;
|
|
||||||
return SingleChildScrollView(
|
|
||||||
padding: EdgeInsets.fromLTRB(
|
|
||||||
AppSpacing.xl,
|
|
||||||
AppSpacing.lg,
|
|
||||||
AppSpacing.xl,
|
|
||||||
AppSpacing.xl + bottom,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
LucideIcons.folder_open,
|
|
||||||
size: 48,
|
|
||||||
color: AppColors.textSecondary.withValues(alpha: 0.9),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
|
||||||
Text(
|
|
||||||
isLimitedAccess
|
|
||||||
? 'No photos shared with this app'
|
|
||||||
: 'No photos available',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.md),
|
|
||||||
Text(
|
|
||||||
isLimitedAccess
|
|
||||||
? 'You allowed access to only some photos, or none are '
|
|
||||||
'selected for this app. Add photos or change permission, '
|
|
||||||
'then try again.'
|
|
||||||
: 'We could not load any images. Check photo access in '
|
|
||||||
'Settings or try again.',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
height: 1.4,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.xl),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: AppColors.primary,
|
|
||||||
foregroundColor: AppColors.surface,
|
|
||||||
minimumSize: const Size(double.infinity, 48),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onPressed: () => onManageAccess(),
|
|
||||||
child: Text(
|
|
||||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS
|
|
||||||
? 'Select photos'
|
|
||||||
: 'Manage photo access',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.sm),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => onOpenSettings(),
|
|
||||||
child: const Text('Open Settings'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => onRetry(),
|
|
||||||
child: const Text('Try again'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppSpacing.md),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () => onUseCamera(),
|
|
||||||
icon: const Icon(LucideIcons.camera, size: 20),
|
|
||||||
label: const Text('Take photo with camera'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CameraGridTile extends StatelessWidget {
|
|
||||||
const _CameraGridTile({required this.onTap});
|
|
||||||
|
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Material(
|
return Material(
|
||||||
color: AppColors.surfaceAlt,
|
color: AppColors.surfaceAlt,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Column(
|
borderRadius: BorderRadius.circular(14),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: Padding(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(
|
||||||
Icon(
|
horizontal: AppSpacing.lg,
|
||||||
LucideIcons.camera,
|
vertical: AppSpacing.lg,
|
||||||
size: 32,
|
),
|
||||||
color: AppColors.primary.withValues(alpha: 0.9),
|
child: Row(
|
||||||
),
|
children: [
|
||||||
const SizedBox(height: AppSpacing.sm),
|
Container(
|
||||||
Text(
|
width: 52,
|
||||||
'Camera',
|
height: 52,
|
||||||
style: TextStyle(
|
decoration: BoxDecoration(
|
||||||
fontSize: 12,
|
color: AppColors.primary.withValues(alpha: 0.12),
|
||||||
fontWeight: FontWeight.w600,
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: AppColors.textSecondary.withValues(alpha: 0.95),
|
),
|
||||||
|
child: Icon(icon, size: 26, color: AppColors.primary),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: AppSpacing.lg),
|
||||||
],
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.3,
|
||||||
|
color: AppColors.textSecondary.withValues(alpha: 0.95),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
LucideIcons.chevron_right,
|
||||||
|
size: 20,
|
||||||
|
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -36,7 +36,6 @@ dependencies:
|
|||||||
flutter_native_splash: ^2.4.7
|
flutter_native_splash: ^2.4.7
|
||||||
android_id: ^0.5.1
|
android_id: ^0.5.1
|
||||||
visibility_detector: ^0.4.0+2
|
visibility_detector: ^0.4.0+2
|
||||||
photo_manager: ^3.9.0
|
|
||||||
image: ^4.5.4
|
image: ^4.5.4
|
||||||
camera: ^0.12.0+1
|
camera: ^0.12.0+1
|
||||||
permission_handler: ^12.0.1
|
permission_handler: ^12.0.1
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user