import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../data/ai/model_lifecycle.dart'; import '../../state/ai_providers.dart'; class SettingsScreen extends ConsumerWidget { const SettingsScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { return Scaffold( appBar: AppBar(title: const Text('설정')), body: ListView( children: const [ _SectionHeader('AI 도움'), _AiSection(), ], ), ); } } class _SectionHeader extends StatelessWidget { final String title; const _SectionHeader(this.title); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), child: Text( title, style: Theme.of(context).textTheme.titleSmall, ), ); } } class _AiSection extends ConsumerWidget { const _AiSection(); @override Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(aiSettingsProvider); final availability = ref.watch(modelAvailabilityProvider); final download = ref.watch(modelDownloadControllerProvider); final optIn = settings.maybeWhen(data: (v) => v, orElse: () => false); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SwitchListTile( title: const Text('AI 도움 켜기'), subtitle: const Text( 'Gemma 4 E2B 모델 ≈ 1.5GB. 모든 처리는 단말에서 일어납니다.', ), value: optIn, onChanged: (v) async { if (v) { final ok = await _confirmOptIn(context); if (ok != true) return; } else { final ok = await _confirmOptOut(context); if (ok != true) return; } final freed = await ref.read(aiSettingsControllerProvider).setOptIn(v); if (!context.mounted) return; if (!v && freed > 0) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('공간 확보됨 ${_fmtMB(freed)}')), ); } }, ), availability.when( loading: () => const ListTile(title: Text('상태 확인 중...')), error: (e, _) => ListTile(title: Text('상태 오류: $e')), data: (a) => ListTile( title: const Text('모델 상태'), subtitle: Text(_describe(a)), ), ), if (optIn && download != null) _DownloadProgressTile(progress: download, ref: ref), const Padding( padding: EdgeInsets.fromLTRB(16, 8, 16, 16), child: Text( 'OQ-1 미해결: 정확한 모델 URL + SHA 가 픽스되기 전까지 ' '다운로드는 동작하지 않습니다. (Architect/Developer 인계 사항)', style: TextStyle(fontSize: 12, color: Colors.grey), ), ), ], ); } String _describe(ModelAvailability a) { switch (a) { case ModelAvailability.ready: return '사용 가능'; case ModelAvailability.missing: return '미설치 — 토글을 켜면 다운로드를 시작합니다'; case ModelAvailability.corrupt: return '손상됨 — 토글을 끄고 다시 켜주세요'; case ModelAvailability.downloading: return '다운로드 중 / 일시정지됨'; } } String _fmtMB(int bytes) { final mb = bytes / (1024 * 1024); return '${mb.toStringAsFixed(1)} MB'; } Future _confirmOptIn(BuildContext context) { return showDialog( context: context, builder: (_) => AlertDialog( title: const Text('AI 도움 켜기'), content: const Text( 'Gemma 4 E2B 모델 ≈ 1.5GB 를 다운로드합니다.\n' '- WiFi 권장\n' '- 모든 처리는 단말에서만 일어나며, 입력 텍스트는 외부로 전송되지 않습니다.\n' '- 끄면 즉시 삭제됩니다.', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('취소'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('동의 후 다운로드'), ), ], ), ); } Future _confirmOptOut(BuildContext context) { return showDialog( context: context, builder: (_) => AlertDialog( title: const Text('AI 도움 끄기'), content: const Text( '모델 파일 ≈ 1.5GB 가 즉시 삭제됩니다. ' '다시 켜면 다시 다운로드해야 합니다.', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('취소'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('끄고 삭제'), ), ], ), ); } } /// AC2: shows download progress + pause/resume/restart controls. class _DownloadProgressTile extends StatelessWidget { final DownloadProgress progress; final WidgetRef ref; const _DownloadProgressTile({required this.progress, required this.ref}); @override Widget build(BuildContext context) { final controller = ref.read(modelDownloadControllerProvider.notifier); final pct = progress.totalBytes > 0 ? (progress.bytesReceived / progress.totalBytes).clamp(0.0, 1.0) : null; final pctText = pct != null ? '${(pct * 100).toStringAsFixed(0)}%' : '계산 중...'; final size = '${_fmtBytes(progress.bytesReceived)}' '${progress.totalBytes > 0 ? " / ${_fmtBytes(progress.totalBytes)}" : ""}'; Widget controls; switch (progress.state) { case DownloadState.downloading: controls = TextButton.icon( icon: const Icon(Icons.pause), label: const Text('일시정지'), onPressed: controller.pause, ); break; case DownloadState.paused: controls = TextButton.icon( icon: const Icon(Icons.play_arrow), label: const Text('재개'), onPressed: controller.resume, ); break; case DownloadState.failed: controls = TextButton.icon( icon: const Icon(Icons.refresh), label: const Text('다시 시도'), onPressed: controller.resume, ); break; case DownloadState.completed: controls = const Text('완료', style: TextStyle(color: Colors.green)); break; case DownloadState.idle: controls = const SizedBox.shrink(); break; } return Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Expanded(child: Text('$size · $pctText')), controls, ], ), const SizedBox(height: 4), LinearProgressIndicator(value: pct), if (progress.state == DownloadState.failed && progress.errorMessage != null) Padding( padding: const EdgeInsets.only(top: 4), child: Text( '에러: ${progress.errorMessage}', style: const TextStyle(fontSize: 12, color: Colors.red), ), ), ], ), ); } String _fmtBytes(int bytes) { if (bytes < 1024) return '$bytes B'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; if (bytes < 1024 * 1024 * 1024) { return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; } }