feat: 초기 커밋
- Progress Quest 6.4 Flutter 포팅 프로젝트 - 게임 루프, 상태 관리, UI 구현 - 캐릭터 생성, 인벤토리, 장비, 주문 시스템 - 시장/판매/구매 메커니즘
This commit is contained in:
314
lib/src/features/front/front_screen.dart
Normal file
314
lib/src/features/front/front_screen.dart
Normal file
@@ -0,0 +1,314 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FrontScreen extends StatelessWidget {
|
||||
const FrontScreen({super.key, this.onNewCharacter, this.onLoadSave});
|
||||
|
||||
/// "New character" 버튼 클릭 시 호출
|
||||
final void Function(BuildContext context)? onNewCharacter;
|
||||
|
||||
/// "Load save" 버튼 클릭 시 호출
|
||||
final Future<void> Function(BuildContext context)? onLoadSave;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [colorScheme.surfaceContainerHighest, colorScheme.surface],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 960),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_HeroHeader(theme: theme, colorScheme: colorScheme),
|
||||
const SizedBox(height: 24),
|
||||
_ActionRow(
|
||||
onNewCharacter: onNewCharacter != null
|
||||
? () => onNewCharacter!(context)
|
||||
: () => _showPlaceholder(context),
|
||||
onLoadSave: onLoadSave != null
|
||||
? () => onLoadSave!(context)
|
||||
: () => _showPlaceholder(context),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const _StatusCards(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showPlaceholder(BuildContext context) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Core gameplay loop is coming next. See doc/progress-quest-flutter-plan.md for milestones.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _HeroHeader extends StatelessWidget {
|
||||
const _HeroHeader({required this.theme, required this.colorScheme});
|
||||
|
||||
final ThemeData theme;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
colorScheme.primary.withValues(alpha: 0.9),
|
||||
colorScheme.primaryContainer,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.primary.withValues(alpha: 0.18),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.auto_awesome, color: colorScheme.onPrimary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Ascii Never Die',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: colorScheme.onPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Offline Progress Quest (PQ 6.4) rebuilt with Flutter.',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onPrimary.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: const [
|
||||
_Tag(icon: Icons.cloud_off_outlined, label: 'No network'),
|
||||
_Tag(icon: Icons.timer_outlined, label: 'Idle RPG loop'),
|
||||
_Tag(icon: Icons.storage_rounded, label: 'Local saves'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionRow extends StatelessWidget {
|
||||
const _ActionRow({required this.onNewCharacter, required this.onLoadSave});
|
||||
|
||||
final VoidCallback onNewCharacter;
|
||||
final VoidCallback onLoadSave;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: onNewCharacter,
|
||||
icon: const Icon(Icons.casino_outlined),
|
||||
label: const Text('New character'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
textStyle: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onLoadSave,
|
||||
icon: const Icon(Icons.folder_open),
|
||||
label: const Text('Load save'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
textStyle: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => _showPlaceholder(context),
|
||||
icon: const Icon(Icons.menu_book_outlined),
|
||||
label: const Text('View build plan'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusCards extends StatelessWidget {
|
||||
const _StatusCards();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: const [
|
||||
_InfoCard(
|
||||
icon: Icons.route_outlined,
|
||||
title: 'Build roadmap',
|
||||
points: [
|
||||
'Port PQ 6.4 data set (Config.dfm) into Dart constants.',
|
||||
'Recreate quest/task loop with deterministic RNG + saves.',
|
||||
'Deliver offline-first storage (GZip JSON) across platforms.',
|
||||
],
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
_InfoCard(
|
||||
icon: Icons.auto_fix_high_outlined,
|
||||
title: 'Tech stack',
|
||||
points: [
|
||||
'Flutter (Material 3) with multiplatform targets enabled.',
|
||||
'path_provider + shared_preferences for local storage hooks.',
|
||||
'Strict lints with package imports enforced from day one.',
|
||||
],
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
_InfoCard(
|
||||
icon: Icons.checklist_rtl,
|
||||
title: 'Today’s focus',
|
||||
points: [
|
||||
'Set up scaffold + lints.',
|
||||
'Wire seed theme and initial navigation shell.',
|
||||
'Keep reference assets under example/pq for parity.',
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoCard extends StatelessWidget {
|
||||
const _InfoCard({required this.title, required this.points, this.icon});
|
||||
|
||||
final String title;
|
||||
final List<String> points;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Card(
|
||||
elevation: 3,
|
||||
shadowColor: colorScheme.shadow.withValues(alpha: 0.2),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, color: colorScheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
...points.map(
|
||||
(point) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 3),
|
||||
child: Icon(Icons.check_circle_outline, size: 18),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(point, style: theme.textTheme.bodyMedium),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Tag extends StatelessWidget {
|
||||
const _Tag({required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Chip(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
backgroundColor: colorScheme.onPrimary.withValues(alpha: 0.14),
|
||||
avatar: Icon(icon, color: colorScheme.onPrimary, size: 16),
|
||||
label: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
side: BorderSide.none,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
);
|
||||
}
|
||||
}
|
||||
107
lib/src/features/front/save_picker_dialog.dart
Normal file
107
lib/src/features/front/save_picker_dialog.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:askiineverdie/src/core/storage/save_service.dart'
|
||||
show SaveFileInfo;
|
||||
|
||||
/// 저장 파일 선택 다이얼로그
|
||||
/// 선택된 파일명을 반환하거나, 취소 시 null 반환
|
||||
class SavePickerDialog extends StatelessWidget {
|
||||
const SavePickerDialog({super.key, required this.saves});
|
||||
|
||||
final List<SaveFileInfo> saves;
|
||||
|
||||
/// 다이얼로그 표시 및 결과 반환
|
||||
static Future<String?> show(
|
||||
BuildContext context,
|
||||
List<SaveFileInfo> saves,
|
||||
) async {
|
||||
if (saves.isEmpty) {
|
||||
// 저장 파일이 없으면 안내 메시지
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('저장된 게임이 없습니다.')));
|
||||
return null;
|
||||
}
|
||||
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => SavePickerDialog(saves: saves),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.folder_open, color: colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
const Text('Load Game'),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 400),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: saves.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final save = saves[index];
|
||||
return _SaveListTile(
|
||||
save: save,
|
||||
onTap: () => Navigator.of(context).pop(save.fileName),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(null),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SaveListTile extends StatelessWidget {
|
||||
const _SaveListTile({required this.save, required this.onTap});
|
||||
|
||||
final SaveFileInfo save;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final dateFormat = DateFormat('yyyy-MM-dd HH:mm');
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.save),
|
||||
title: Text(
|
||||
save.displayName,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${dateFormat.format(save.modifiedAt)} · ${_formatSize(save.sizeBytes)}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
String _formatSize(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user