330 lines
11 KiB
Dart
330 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 '../../core/user/user_state.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: PreferredSize(
|
|
preferredSize: const Size.fromHeight(56),
|
|
child: TopNavBar(
|
|
title: 'Gallery',
|
|
credits: UserCreditsData.of(context)?.creditsDisplay ?? '--',
|
|
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
|
|
),
|
|
),
|
|
body: _loading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: _error != null
|
|
? Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
_error!,
|
|
textAlign: TextAlign.center,
|
|
style: 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: 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: [
|
|
BoxShadow(
|
|
color: AppColors.shadowMedium,
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(24),
|
|
child: mediaItem.imageUrl != null
|
|
? CachedNetworkImage(
|
|
imageUrl: mediaItem.imageUrl!,
|
|
fit: BoxFit.cover,
|
|
placeholder: (_, __) => Container(
|
|
color: AppColors.surfaceAlt,
|
|
),
|
|
errorWidget: (_, __, ___) => Container(
|
|
color: AppColors.surfaceAlt,
|
|
),
|
|
)
|
|
: _VideoThumbnailCover(videoUrl: mediaItem.videoUrl!),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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(),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|