petsHero-AI/lib/features/gallery/gallery_screen.dart
2026-03-15 12:02:36 +08:00

332 lines
11 KiB
Dart

import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../core/api/api_config.dart';
import '../../core/api/services/image_api.dart';
import '../../core/auth/auth_service.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_spacing.dart';
import '../../shared/widgets/top_nav_bar.dart';
import 'models/gallery_task_item.dart';
import 'video_thumbnail_cache.dart';
/// Gallery screen - matches Pencil hpwBg
class GalleryScreen extends StatefulWidget {
const GalleryScreen({super.key, required this.isActive});
final bool isActive;
@override
State<GalleryScreen> createState() => _GalleryScreenState();
}
class _GalleryScreenState extends State<GalleryScreen> {
List<GalleryTaskItem> _tasks = [];
bool _loading = true;
bool _loadingMore = false;
String? _error;
int _currentPage = 1;
bool _hasNext = false;
bool _hasLoadedOnce = false;
final ScrollController _scrollController = ScrollController();
static const int _pageSize = 20;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
if (widget.isActive) _loadTasks(refresh: true);
}
@override
void didUpdateWidget(covariant GalleryScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isActive && !oldWidget.isActive && !_hasLoadedOnce) {
_loadTasks(refresh: true);
}
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_loadingMore || !_hasNext || _tasks.isEmpty) return;
final pos = _scrollController.position;
if (pos.pixels >= pos.maxScrollExtent - 200) {
_loadTasks(refresh: false);
}
}
Future<void> _loadTasks({bool refresh = true}) async {
if (refresh) {
setState(() {
_loading = true;
_error = null;
_currentPage = 1;
});
} else {
if (_loadingMore || !_hasNext) return;
setState(() => _loadingMore = true);
}
try {
await AuthService.loginComplete;
final page = refresh ? 1 : _currentPage;
final res = await ImageApi.getMyTasks(
sentinel: ApiConfig.appId,
trophy: page.toString(),
heatmap: _pageSize.toString(),
);
if (!mounted) return;
if (res.isSuccess && res.data != null) {
final data = res.data as Map<String, dynamic>?;
final intensify = data?['intensify'] as List<dynamic>? ?? [];
final list = intensify
.whereType<Map<String, dynamic>>()
.map((e) => GalleryTaskItem.fromJson(e))
.toList();
final hasNext = data?['manifest'] as bool? ?? false;
setState(() {
if (refresh) {
_tasks = list;
} else {
_tasks = [..._tasks, ...list];
}
_currentPage = page + 1;
_hasNext = hasNext;
_loading = false;
_loadingMore = false;
_hasLoadedOnce = true;
});
} else {
setState(() {
if (refresh) _tasks = [];
_loading = false;
_loadingMore = false;
_error = res.msg.isNotEmpty ? res.msg : 'Failed to load';
});
}
} catch (e) {
if (mounted) {
setState(() {
if (refresh) _tasks = [];
_loading = false;
_loadingMore = false;
_error = e.toString();
});
}
}
}
List<GalleryMediaItem> get _gridItems {
final items = <GalleryMediaItem>[];
for (final task in _tasks) {
items.addAll(task.mediaItems);
}
return items;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: const PreferredSize(
preferredSize: Size.fromHeight(56),
child: TopNavBar(
title: 'My Gallery',
),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_error!,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.textSecondary),
),
const SizedBox(height: AppSpacing.lg),
TextButton(
onPressed: _loadTasks,
child: const Text('Retry'),
),
],
),
)
: LayoutBuilder(
builder: (context, constraints) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 390),
child: RefreshIndicator(
onRefresh: () => _loadTasks(refresh: true),
child: _gridItems.isEmpty && !_loading
? SingleChildScrollView(
physics:
const AlwaysScrollableScrollPhysics(),
child: SizedBox(
height: constraints.maxHeight - 100,
child: const Center(
child: Text(
'No images yet',
style: TextStyle(
color: AppColors.textSecondary,
),
),
),
),
)
: GridView.builder(
physics:
const AlwaysScrollableScrollPhysics(),
controller: _scrollController,
padding: EdgeInsets.fromLTRB(
AppSpacing.screenPadding,
AppSpacing.xl,
AppSpacing.screenPadding,
AppSpacing.screenPaddingLarge +
(_loadingMore ? 48.0 : 0),
),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 165 / 248,
mainAxisSpacing: AppSpacing.xl,
crossAxisSpacing: AppSpacing.xl,
),
itemCount: _gridItems.length +
(_loadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _gridItems.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
),
);
}
return _GalleryCard(
mediaItem: _gridItems[index],
onTap: () {
Navigator.of(context).pushNamed(
'/result',
arguments: _gridItems[index],
);
},
);
},
),
),
),
);
},
),
);
}
}
class _GalleryCard extends StatelessWidget {
const _GalleryCard({
required this.mediaItem,
required this.onTap,
});
final GalleryMediaItem mediaItem;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: LayoutBuilder(
builder: (context, constraints) {
return Container(
width: constraints.maxWidth,
height: constraints.maxHeight,
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppColors.border, width: 1),
boxShadow: const [
BoxShadow(
color: AppColors.shadowMedium,
blurRadius: 12,
offset: Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: mediaItem.isVideo
? _VideoThumbnailCover(videoUrl: mediaItem.videoUrl!)
: (mediaItem.imageUrl != null &&
mediaItem.imageUrl!.isNotEmpty)
? CachedNetworkImage(
imageUrl: mediaItem.imageUrl!,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: AppColors.surfaceAlt,
),
errorWidget: (_, __, ___) => Container(
color: AppColors.surfaceAlt,
),
)
: Container(color: AppColors.surfaceAlt),
),
);
},
),
);
}
}
class _VideoThumbnailCover extends StatelessWidget {
const _VideoThumbnailCover({required this.videoUrl});
final String videoUrl;
@override
Widget build(BuildContext context) {
return FutureBuilder<Uint8List?>(
future: VideoThumbnailCache.instance.getThumbnail(videoUrl),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return Image.memory(
snapshot.data!,
fit: BoxFit.cover,
);
}
return Container(
color: AppColors.surfaceAlt,
child: snapshot.connectionState == ConnectionState.waiting
? const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: const SizedBox.shrink(),
);
},
);
}
}