import 'package:flutter/material.dart'; import '../../core/data/backend.dart' show BackendException; import '../../core/i18n/app_locale.dart'; import '../../core/push/push_messaging.dart'; import '../../core/theme/tokens.dart'; import '../../core/ui/app_icons.dart'; import '../../core/ui/sheet_route.dart'; import '../home/coach_mark.dart'; import '../home/home_data.dart'; import '../home/nebula_create_screen.dart'; import '../my/my_data.dart' show nicknameMaxLength, nicknameMinLength, nicknameReserved; import '../planet/planet_data.dart' show countryName; import 'auth_repository.dart'; import 'onboarding_data.dart'; import 'signup_screens.dart'; /// 온보딩 ① 소개 3장 — FR-CMN-03 「소개는 3장으로 고정 · 건너뛰기를 눌러도 로그인 화면으로」. /// /// 앱 Figma 사본에 소개 화면이 없다(해석 확인 Q-OB-02 A 「사본의 컴포넌트 · 색 · 글꼴 · 화면 틀로 /// 구성」). 문구 · 이미지는 발주사 제공본이 오면 바꾼다(해석 확인 요청 E-Q7). class IntroScreen extends StatefulWidget { const IntroScreen({super.key, required this.onDone}); final VoidCallback onDone; @override State createState() => _IntroScreenState(); } class _IntroScreenState extends State { final _pages = PageController(); int _page = 0; @override void dispose() { _pages.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final l10n = context.l10n; final slides = [ (l10n.onbIntro1Title, l10n.onbIntro1Body), (l10n.onbIntro2Title, l10n.onbIntro2Body), (l10n.onbIntro3Title, l10n.onbIntro3Body), ]; final last = _page == slides.length - 1; return Scaffold( body: SafeArea( child: Column( children: [ Align( alignment: Alignment.centerRight, child: TextButton( onPressed: widget.onDone, child: Text(l10n.onbSkip), ), ), Expanded( child: PageView( controller: _pages, onPageChanged: (i) => setState(() => _page = i), children: [ for (final (title, body) in slides) Padding( padding: const EdgeInsets.all(AppSpace.s6), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const SizedBox.square( dimension: 120, child: DecoratedBox( decoration: BoxDecoration( color: AppColors.cosmicAccent, shape: BoxShape.circle, ), ), ), const SizedBox(height: AppSpace.s6), Text( title, textAlign: TextAlign.center, style: _title, ), const SizedBox(height: AppSpace.s3), Text(body, textAlign: TextAlign.center, style: _body), ], ), ), ], ), ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ for (var i = 0; i < slides.length; i++) Container( margin: const EdgeInsets.all(4), width: 8, height: 8, decoration: BoxDecoration( shape: BoxShape.circle, color: i == _page ? AppColors.cosmicAccent : AppColors.onCosmicMuted, ), ), ], ), Padding( padding: const EdgeInsets.all(AppSpace.s4), child: _PrimaryButton( label: last ? l10n.commonStart : l10n.commonNext, onPressed: last ? widget.onDone : () => _pages.nextPage( duration: const Duration(milliseconds: 250), curve: Curves.easeOut, ), ), ), ], ), ), ); } } enum _Step { terms, profile, artist, planet, permissions } /// 온보딩 ②~⑥ — 로그인(가입 · 로그인 버튼 구분 없이) 뒤 서버에 약관 동의가 없으면 여기로 온다. /// /// 순서는 10/3 통합회신 1항 그대로: 약관 → 프로필 → 관심 아티스트/Primary → 행성 가입 또는 성운 /// 생성 → 권한 안내. 약관을 기록한 뒤 앱을 닫으면 다음 실행은 HOME(`홈_초기사용자`)으로 간다 /// (Q-OB-02 A 「`홈_초기사용자` — 가입 흐름을 마치기 전에 앱을 닫은 사용자에게 사본대로」). class OnboardingFlow extends StatefulWidget { const OnboardingFlow({ super.key, required this.auth, required this.repository, required this.home, this.push, }); final AuthRepository auth; final OnboardingRepository repository; /// 성운 생성(`홈/성운 생성` 사본 화면)이 쓰는 저장소. final HomeRepository home; /// 권한 안내의 OS 알림 팝업. 목 모드에서는 null. final PushMessaging? push; @override State createState() => _OnboardingFlowState(); } class _OnboardingFlowState extends State { _Step _step = _Step.terms; bool _busy = false; /// ② 에서 서버에 기록한 동의 항목. ③ 에서 뒤로 오면 이 상태로 체크해 보여 준다. Set? _agreed; /// ③ 에서 서버에 귀속한 추천인 코드. ④ 에서 뒤로 와 다시 「다음」이면 또 부르지 않는다 /// (서버는 1인 1회라 두 번째 호출을 거절한다). String? _referred; /// ④ 에서 고른 팬덤 — 고른 순서. 첫 번째가 대표(Primary). final _picked = []; @override void initState() { super.initState(); // 가입 모드는 제공자 인증 전에 약관을 받았다 — 다시 묻지 않고 기록만 한다. final pending = widget.auth.pendingConsent; if (pending != null) _agree(pending); } Future _run(Future Function() task) async { setState(() => _busy = true); try { await task(); } on BackendException catch (e) { _toast(e.message); } catch (_) { if (mounted) _toast(context.l10n.commonRequestFailed); } finally { if (mounted) setState(() => _busy = false); } } void _toast(String message) => ScaffoldMessenger.maybeOf(context) ?..hideCurrentSnackBar() ..showSnackBar(SnackBar(content: Text(message))); void _agree(Set agreed) { if (_busy) return; // ③ 에서 뒤로 왔다가 그대로 「완료」면 기록을 다시 남기지 않는다(서버 동의 기록 유지). final prev = _agreed; if (prev != null && prev.length == agreed.length && prev.containsAll(agreed)) { setState(() => _step = _Step.profile); return; } _run(() async { await widget.repository.agree(agreed, signupTermsVersion); _agreed = agreed; if (mounted) setState(() => _step = _Step.profile); }); } void _back() => setState( () => _step = switch (_step) { // 명세 `511:86825` 「상단 뒤로가기 — 이전 화면」. 이전 = ② 약관(동의 체크 유지). _Step.profile => _Step.terms, _Step.artist => _Step.profile, _Step.planet => _Step.artist, final s => s, }, ); Future _finish() async { try { await widget.push?.requestPermission(); } catch (_) { // 거부 · 플러그인 오류여도 다음으로 간다(FR-CMN-08). } await armCoachMark(); widget.auth.completeOnboarding(); } @override Widget build(BuildContext context) { final body = switch (_step) { _Step.terms => TermsScreen( key: ValueKey(_agreed), initial: _agreed ?? const {}, onAgreed: _agree, onBack: widget.auth.signOut, ), _Step.profile => _ProfileStep( repository: widget.repository, referred: _referred, onReferred: (code) => _referred = code, onBack: _back, onDone: () => setState(() => _step = _Step.artist), ), _Step.artist => _ArtistStep( repository: widget.repository, picked: _picked, onNext: () => setState(() => _step = _Step.planet), onBack: _back, ), _Step.planet => _PlanetStep( picked: _picked, busy: _busy, onBack: _back, onJoin: () => _run(() async { await widget.repository.join([for (final f in _picked) f.id]); if (mounted) setState(() => _step = _Step.permissions); }), onCreateNebula: () async { final created = await Navigator.of(context).push( SheetRoute( builder: (_) => NebulaCreateScreen(repository: widget.home), ), ); if (created != null && mounted) { setState(() => _step = _Step.permissions); } }, ), _Step.permissions => _PermissionStep(onContinue: _finish), }; return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (!didPop) _back(); }, child: body, ); } } /// ③ 프로필 — 닉네임 2~20(중복 허용 · B-52) · 국가/지역 · 표시 언어 · 추천인 코드(선택). /// 사본 `MY/프로필 설정` 구성을 따른다. 국가 기본값은 기기 지역(Figma `554:24126` 「앱 기동 단계에서 /// 조회된 국가/언어를 디폴트」). class _ProfileStep extends StatefulWidget { const _ProfileStep({ required this.repository, required this.referred, required this.onReferred, required this.onDone, required this.onBack, }); final OnboardingRepository repository; /// 이미 귀속한 추천인 코드(뒤로 왔을 때 채워 보여 준다). final String? referred; final ValueChanged onReferred; final VoidCallback onDone; final VoidCallback onBack; @override State<_ProfileStep> createState() => _ProfileStepState(); } class _ProfileStepState extends State<_ProfileStep> { final _nickname = TextEditingController(); late final _referral = TextEditingController(text: widget.referred); String? _country = _deviceCountry(); Locale? _locale; String? _nicknameError; String? _referralError; bool _busy = false; /// 기기 지역. 없으면 비워 두고 사용자가 고르게 한다(임의 국가로 채우지 않는다). static String? _deviceCountry() { final code = WidgetsBinding.instance.platformDispatcher.locale.countryCode ?.toUpperCase(); return code != null && isoCountries.contains(code) ? code : null; } @override void initState() { super.initState(); _nickname.addListener(() => setState(() => _nicknameError = null)); widget.repository.currentProfile().then((p) { if (!mounted) return; setState(() { if (_nickname.text.isEmpty) _nickname.text = p.nickname; if (p.country != null && isoCountries.contains(p.country)) { _country = p.country!; } }); }, onError: (_) {}); } @override void dispose() { _nickname.dispose(); _referral.dispose(); super.dispose(); } bool get _valid { final n = _nickname.text.trim().characters.length; return n >= nicknameMinLength && n <= nicknameMaxLength && _country != null; } Future _save() async { final l10n = context.l10n; final nickname = _nickname.text.trim(); if (nicknameReserved(nickname)) { setState(() => _nicknameError = l10n.nicknameReserved); return; } final scope = LocaleScope.maybeOf(context); final locale = _locale ?? scope?.locale ?? fallbackLocale; setState(() => _busy = true); try { await widget.repository.saveProfile( nickname: nickname, country: _country!, locale: localeTag(locale), ); if (_locale != null) await scope?.select(_locale!); final code = _referral.text.trim().toUpperCase(); if (code.isNotEmpty && code != widget.referred) { try { await widget.repository.attributeReferral(code); widget.onReferred(code); } on BackendException catch (e) { // 거절 사유(형식 · 없는 코드 · 본인 · 상호 · 이미 등록 · 7일 경과)는 Figma `511:86845` // 문구 하나로 보인다(C-119 「오류 문구 재사용」). 통신 실패만 서버 문구 그대로. if (mounted) { setState( () => _referralError = e.code == 'E-SYS-01' ? e.message : l10n.onbReferralInvalid, ); } return; } } widget.onDone(); } on BackendException catch (e) { if (mounted) setState(() => _nicknameError = e.message); } finally { if (mounted) setState(() => _busy = false); } } Future _pickCountry() async { final picked = await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: const Color(0xFF101010), // 사본 `178:23489` 상단 손잡이. showDragHandle: true, builder: (_) => _CountrySheet(current: _country), ); if (picked != null && mounted) setState(() => _country = picked); } @override Widget build(BuildContext context) { final l10n = context.l10n; final current = _locale ?? LocaleScope.maybeOf(context)?.locale; // Figma `511:86825` — 머리줄 「<」 좌측 · 타이틀 중앙(사본 `178:23489` 「회원가입」), 본문 큰 제목 없음. // 입력란은 사본처럼 테두리 상자(`511:86809`). return _StepScaffold( barTitle: l10n.authSignup, onBack: widget.onBack, boxedInputs: true, action: _PrimaryButton( label: l10n.commonNext, onPressed: _valid && !_busy ? _save : null, ), children: [ TextField( controller: _nickname, maxLength: nicknameMaxLength, decoration: InputDecoration( labelText: l10n.settingsNickname, helperText: l10n.onbNicknameHint, errorText: _nicknameError, ), ), const SizedBox(height: AppSpace.s3), // 사본 국가 리스트 모달 `178:23489`(명세 `511:86786` · `511:86796`) — 국기 · 국가명 · // 단수 선택 체크, 「선택」을 눌러야 적용하고 닫힌다. InkWell( onTap: _pickCountry, child: InputDecorator( decoration: InputDecoration( labelText: l10n.onbCountry, suffixIcon: const Icon(Icons.expand_more), ), isEmpty: _country == null, child: _country == null ? null : Text('${countryFlag(_country!)} ${countryName(_country!)}'), ), ), const SizedBox(height: AppSpace.s3), DropdownButtonFormField( initialValue: appLocales.contains(current) ? current : null, isExpanded: true, decoration: InputDecoration(labelText: l10n.onbLanguage), items: [ for (final l in appLocales) DropdownMenuItem(value: l, child: Text(languageName(l))), ], onChanged: (v) => setState(() => _locale = v), ), const SizedBox(height: AppSpace.s3), // 추천인 코드 입력칸 — Figma 이메일 가입 `333:18620`(입력칸) · `511:86845`(오류) · // `511:86809`(선택 입력). 10/3 통합회신 7항으로 가입 직후 온보딩에 둔다(C-119). TextField( controller: _referral, textCapitalization: TextCapitalization.characters, maxLength: 10, onChanged: (_) => setState(() => _referralError = null), decoration: InputDecoration( labelText: l10n.onbReferral, errorText: _referralError, ), ), ], ); } } /// 국가 리스트 모달 — 사본 `178:23489`. /// /// 명세 `511:86762` 타이틀 좌측 · 닫기 우측(누르면 모달 종료) / `511:86786` 국기 좌측 · 국가명 · /// 선택한 국가에만 오른쪽 체크(단수) / `511:86796` 국가를 골라야 「선택」이 켜지고, 누르면 적용 후 닫힘. class _CountrySheet extends StatefulWidget { const _CountrySheet({required this.current}); final String? current; @override State<_CountrySheet> createState() => _CountrySheetState(); } class _CountrySheetState extends State<_CountrySheet> { late String? _picked = widget.current; @override Widget build(BuildContext context) => SafeArea( child: SizedBox( height: MediaQuery.sizeOf(context).height * 0.8, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 0, 4, 0), child: Row( children: [ Expanded( child: Text(context.l10n.onbCountrySheetTitle, style: _title), ), IconButton( tooltip: context.l10n.commonClose, onPressed: () => Navigator.of(context).pop(), icon: const ImageIcon(AppIcons.x, size: 24), ), ], ), ), Expanded( child: ListView( children: [ for (final c in sortedCountries()) ListTile( onTap: () => setState(() => _picked = c), leading: Text( countryFlag(c), style: const TextStyle(fontSize: 22), ), title: Text(countryName(c)), trailing: c == _picked ? const Icon( Icons.check_circle_outline, color: AppColors.cosmicAccent, ) : null, ), ], ), ), Padding( padding: const EdgeInsets.all(AppSpace.s4), child: _PrimaryButton( label: context.l10n.commonSelect, onPressed: _picked == null ? null : () => Navigator.of(context).pop(_picked), ), ), ], ), ), ); } /// ④ 관심 아티스트 / Primary Fandom — 최소 1개(FR-CMN-06). 처음 고른 팬덤이 대표가 된다. /// 찾는 아티스트가 없으면 ⑤ 에서 성운을 만든다. class _ArtistStep extends StatefulWidget { const _ArtistStep({ required this.repository, required this.picked, required this.onNext, required this.onBack, }); final OnboardingRepository repository; final List picked; final VoidCallback onNext; final VoidCallback onBack; @override State<_ArtistStep> createState() => _ArtistStepState(); } class _ArtistStepState extends State<_ArtistStep> { late Future> _list = widget.repository.fandoms(); final _query = TextEditingController(); @override void dispose() { _query.dispose(); super.dispose(); } void _toggle(OnboardingFandom f) => setState(() { final i = widget.picked.indexWhere((p) => p.id == f.id); i >= 0 ? widget.picked.removeAt(i) : widget.picked.add(f); }); @override Widget build(BuildContext context) { final l10n = context.l10n; final picked = widget.picked; return _StepScaffold( title: l10n.onbArtistTitle, subtitle: l10n.onbArtistBody, onBack: widget.onBack, action: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _PrimaryButton( label: l10n.commonNext, onPressed: picked.isEmpty ? null : widget.onNext, ), TextButton( onPressed: () { picked.clear(); widget.onNext(); }, child: Text(l10n.onbArtistNone), ), ], ), children: [ TextField( controller: _query, textInputAction: TextInputAction.search, decoration: InputDecoration( hintText: l10n.onbArtistSearch, prefixIcon: const Icon(Icons.search), ), onSubmitted: (q) => setState(() => _list = widget.repository.fandoms(query: q)), ), const SizedBox(height: AppSpace.s3), FutureBuilder>( future: _list, builder: (context, snap) { if (snap.hasError) { return TextButton( onPressed: () => setState( () => _list = widget.repository.fandoms(query: _query.text), ), child: Text(l10n.commonRetry), ); } final list = snap.data; if (list == null) { return const Center(child: CircularProgressIndicator()); } if (list.isEmpty) return Text(l10n.commonNoResults); return Column( children: [ for (final f in list) CheckboxListTile( value: picked.any((p) => p.id == f.id), onChanged: (_) => _toggle(f), title: Text(f.name), subtitle: Text( [ if (f.nebula) l10n.onbNebulaTag, l10n.onbMembers('${f.members}'), ].join(' · '), ), secondary: picked.isNotEmpty && picked.first.id == f.id ? Chip(label: Text(l10n.onbPrimary)) : null, ), ], ); }, ), ], ); } } /// ⑤ 행성 가입 또는 성운 생성 — 성운 생성은 사본 `홈/성운 생성`(`340:75466`) 그대로. class _PlanetStep extends StatelessWidget { const _PlanetStep({ required this.picked, required this.busy, required this.onBack, required this.onJoin, required this.onCreateNebula, }); final List picked; final bool busy; final VoidCallback onBack; final VoidCallback onJoin; final VoidCallback onCreateNebula; @override Widget build(BuildContext context) { final l10n = context.l10n; return _StepScaffold( title: l10n.onbPlanetTitle, onBack: onBack, action: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (picked.isNotEmpty) _PrimaryButton( label: l10n.onbPlanetJoin, onPressed: busy ? null : onJoin, ), const SizedBox(height: AppSpace.s2), OutlinedButton( onPressed: busy ? null : onCreateNebula, child: Text(l10n.onbPlanetCreate), ), ], ), children: [ for (final (i, f) in picked.indexed) ListTile( title: Text(f.name), trailing: i == 0 ? Chip(label: Text(l10n.onbPrimary)) : null, ), ], ); } } /// ⑥ 권한 안내 — 한 화면에서 쓰임을 설명하고 OS 팝업은 알림만(FR-CMN-08 · C-84). 건강은 걷기 Quest, /// 위치는 위치 Quest 에 처음 들어갈 때 요청한다. 거부해도 다음으로 간다. class _PermissionStep extends StatefulWidget { const _PermissionStep({required this.onContinue}); final Future Function() onContinue; @override State<_PermissionStep> createState() => _PermissionStepState(); } class _PermissionStepState extends State<_PermissionStep> { bool _busy = false; @override Widget build(BuildContext context) { final l10n = context.l10n; return _StepScaffold( title: l10n.onbPermTitle, subtitle: l10n.onbPermBody, action: _PrimaryButton( label: l10n.onbPermContinue, onPressed: _busy ? null : () async { setState(() => _busy = true); await widget.onContinue(); }, ), children: [ for (final (icon, title, desc) in [ ( Icons.notifications_outlined, l10n.onbPermPush, l10n.onbPermPushDesc, ), (Icons.directions_walk, l10n.onbPermHealth, l10n.onbPermHealthDesc), ( Icons.place_outlined, l10n.onbPermLocation, l10n.onbPermLocationDesc, ), ]) ListTile( leading: Icon(icon, color: AppColors.cosmicAccent), title: Text(title), subtitle: Text(desc), ), ], ); } } /// 단계 화면 틀 — 사본 가입 화면과 같은 코스믹 다크 · 하단 고정 버튼. class _StepScaffold extends StatelessWidget { const _StepScaffold({ this.title, required this.action, required this.children, this.subtitle, this.onBack, this.barTitle, this.boxedInputs = false, }); final String? title; final String? barTitle; final bool boxedInputs; final String? subtitle; final Widget action; final List children; final VoidCallback? onBack; @override Widget build(BuildContext context) => Scaffold( appBar: AppBar( automaticallyImplyLeading: false, leading: onBack == null ? null : BackButton(onPressed: onBack), title: barTitle == null ? null : Text(barTitle!), centerTitle: true, ), body: SafeArea( top: false, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( child: _boxed( context, ListView( padding: const EdgeInsets.all(AppSpace.s4), children: [ if (title != null) ...[ Text(title!, style: _title), if (subtitle != null) ...[ const SizedBox(height: AppSpace.s2), Text(subtitle!, style: _body), ], const SizedBox(height: AppSpace.s5), ], ...children, ], ), ), ), Padding(padding: const EdgeInsets.all(AppSpace.s4), child: action), ], ), ), ); /// 사본 가입 화면 입력란 — 회색 테두리 상자(약관 상자와 같은 선 · 모서리 8). Widget _boxed(BuildContext context, Widget child) { if (!boxedInputs) return child; OutlineInputBorder line(Color c, [double w = 1]) => OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.sm), borderSide: BorderSide(color: c, width: w), ); final theme = Theme.of(context); return Theme( data: theme.copyWith( inputDecorationTheme: theme.inputDecorationTheme.copyWith( border: line(_stroke), enabledBorder: line(_stroke), focusedBorder: line(AppColors.cosmicAccent, 2), errorBorder: line(AppColors.error), focusedErrorBorder: line(AppColors.error, 2), ), ), child: child, ); } } class _PrimaryButton extends StatelessWidget { const _PrimaryButton({required this.label, required this.onPressed}); final String label; final VoidCallback? onPressed; @override Widget build(BuildContext context) => FilledButton( onPressed: onPressed, style: FilledButton.styleFrom( backgroundColor: AppColors.cosmicAccent, foregroundColor: AppColors.onCosmicAccent, disabledBackgroundColor: const Color(0xFF333333), disabledForegroundColor: const Color(0xFF777777), ), child: Text(label), ); } /// 사본 입력란 테두리 — 약관 상자(`signup_screens.dart`)와 같은 값. const _stroke = Color(0xFF545454); const _title = TextStyle( fontFamily: AppFonts.body, fontSize: 20, fontWeight: FontWeight.w600, height: 1.4, color: AppColors.onCosmic, ); const _body = TextStyle( fontFamily: AppFonts.body, fontSize: 14, height: 1.6, color: Color(0xFFCDCDCD), ); /// 국기 이모지 — 지역 표시 기호 두 글자. String countryFlag(String code) => String.fromCharCodes(code.codeUnits.map((c) => c - 0x41 + 0x1F1E6)); /// 국가 시트 순서 — 기기 OS 정렬([platformCountryNames] 순서)을 따르고, OS 가 모르는 코드는 표시명 순으로 뒤에 둔다. /// 사본 `178:23489` 목록은 (첫 줄 독일을 빼면) 국가명 가나다순이고 검색 칸이 없다. 범위는 Q-E10 회신 대기. List sortedCountries() { final rest = isoCountries.toSet(); final ordered = [ for (final c in platformCountryNames.keys) if (rest.remove(c)) c, ]; return ordered..addAll( rest.toList()..sort((a, b) => countryName(a).compareTo(countryName(b))), ); } /// ISO 3166-1 alpha-2 전체. 랭킹 집계 · Admin 사용자 일람의 「거주 국가/지역」 값이다. final List isoCountries = ('AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ ' 'BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK ' 'DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ ' 'GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH ' 'KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM ' 'MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF ' 'PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM ' 'SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US ' 'UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW') .split(' ');