import 'package:flutter/material.dart'; import 'package:friendly_map/modules/app/app.dart'; import '../app_scope.dart'; import '../theme.dart'; import '../widgets/common.dart'; import '../widgets/pin_dialog.dart'; /// 휴대폰 주소록에서 사람을 골라(한 명·여러 명·거른 사람 모두) 인맥과 그룹에 넣는다. /// [groupId] 를 주면 그 그룹이 미리 골라져 있다. class ContactImportScreen extends StatefulWidget { const ContactImportScreen({super.key, this.groupId}); final String? groupId; @override State createState() => _ContactImportScreenState(); } class _ContactImportScreenState extends State { List? _all; bool _denied = false; String _query = ''; String? _label; bool _hideKnown = false; final Set _picked = {}; // 주소록 번호표 bool _busy = false; @override void didChangeDependencies() { super.didChangeDependencies(); if (_all == null && !_busy) _load(); } Future _load() async { final svc = AppScope.of(context).app.contacts; _busy = true; final allowed = svc.available && await svc.requestPermission(); final list = allowed ? await svc.load() : const []; if (!mounted) return; setState(() { _busy = false; _denied = !allowed; _all = list; _picked.removeWhere((id) => !list.any((c) => c.contact.id == id)); }); } List get _shown => ContactImportService.filter(_all ?? const [], ContactFilter(query: _query, label: _label, hideKnown: _hideKnown)); Future _import() async { final app = AppScope.of(context).app; final picked = [for (final c in _all ?? const []) if (_picked.contains(c.contact.id)) c]; final opts = await showModalBottomSheet<_Options>( context: context, showDragHandle: true, isScrollControlled: true, builder: (_) => _OptionsSheet(count: picked.length, preset: widget.groupId), ); if (opts == null || !mounted) return; setState(() => _busy = true); final r = await app.contacts.import(picked, groupIds: opts.groups, sendRequest: opts.request); if (!mounted) return; _picked.clear(); final parts = [ if (r.added > 0) '새로 ${r.added}명', if (r.existing > 0) '이미 있던 ${r.existing}명', if (r.skipped > 0) '건너뜀 ${r.skipped}명', if (r.requested > 0) '친구 요청 ${r.requested}명', ]; showNote(context, parts.isEmpty ? '가져온 사람이 없어요' : '가져왔어요: ${parts.join(', ')}'); await _load(); } @override Widget build(BuildContext context) { final svc = AppScope.of(context).app.contacts; final all = _all; Widget body; if (!svc.available) { body = const EmptyNote('이 기기에서는 주소록을 읽을 수 없어요.'); } else if (all == null) { body = const Center(child: CircularProgressIndicator()); } else if (_denied) { body = Column(mainAxisAlignment: MainAxisAlignment.center, children: [ const EmptyNote('주소록을 읽을 권한이 없어요.\n휴대폰 설정에서 연락처 권한을 허용해 주세요.'), OutlinedButton(onPressed: _load, child: const Text('다시 묻기')), ]); } else { body = _list(all); } return Scaffold( appBar: AppBar(title: const Text('주소록에서 가져오기')), body: body, bottomNavigationBar: _picked.isEmpty ? null : SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: SizedBox( height: 48, child: FilledButton(onPressed: _busy ? null : _import, child: Text('${_picked.length}명 가져오기')), ), ), ), ); } Widget _list(List all) { final shown = _shown; final pickable = [for (final c in shown) if (c.importable) c.contact.id]; final allPicked = pickable.isNotEmpty && pickable.every(_picked.contains); final labels = ContactImportService.labels(all); return Column(children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 6), child: TextField( decoration: const InputDecoration(hintText: '이름 · 초성(ㄱㅁㅅ) · 번호로 찾기', prefixIcon: Icon(Icons.search), isDense: true), onChanged: (v) => setState(() => _query = v), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Wrap(spacing: 6, runSpacing: 6, children: [ ChoiceChip(label: const Text('전체'), selected: _label == null, onSelected: (_) => setState(() => _label = null)), for (final l in labels) ChoiceChip(label: Text(l), selected: _label == l, onSelected: (_) => setState(() => _label = _label == l ? null : l)), FilterChip(label: const Text('새 사람만'), selected: _hideKnown, onSelected: (v) => setState(() => _hideKnown = v)), ]), ), Padding( padding: const EdgeInsets.fromLTRB(20, 4, 8, 0), child: Row(children: [ Expanded(child: Text('${shown.length}명 · ${_picked.length}명 고름', style: const TextStyle(fontSize: 12, color: Pal.muted))), TextButton( onPressed: pickable.isEmpty ? null : () => setState(() => allPicked ? _picked.removeAll(pickable) : _picked.addAll(pickable)), child: Text(allPicked ? '모두 풀기' : '모두 고르기'), ), ]), ), Expanded( child: shown.isEmpty ? const EmptyNote('맞는 사람이 없어요.') : ListView.builder( itemCount: shown.length, itemBuilder: (_, i) { final c = shown[i]; final note = c.isMe ? '내 번호' : c.phone == null ? '쓸 수 있는 번호가 없어요' : c.known != null ? '${displayPhone(c.phone!)} · 이미 인맥에 있어요' : displayPhone(c.phone!); return CheckboxListTile( value: _picked.contains(c.contact.id), onChanged: c.importable ? (v) => setState(() => v == true ? _picked.add(c.contact.id) : _picked.remove(c.contact.id)) : null, secondary: Avatar(c.name, size: 36, color: c.known != null ? Pal.school : Pal.accent), title: Text(c.name), subtitle: Text(note, style: const TextStyle(fontSize: 12, color: Pal.muted)), ); }, ), ), ]); } } class _Options { const _Options(this.groups, this.request); final Set groups; final bool request; } /// 가져오기 전에: 넣을 그룹(여러 개)과 친구 요청을 보낼지 고른다. class _OptionsSheet extends StatefulWidget { const _OptionsSheet({required this.count, this.preset}); final int count; final String? preset; @override State<_OptionsSheet> createState() => _OptionsSheetState(); } class _OptionsSheetState extends State<_OptionsSheet> { late final Set _groups = {if (widget.preset != null) widget.preset!}; bool _request = false; List? _views; @override void didChangeDependencies() { super.didChangeDependencies(); _views ?? _reload(); } Future _reload() async { final v = await AppScope.of(context).app.groups.all(); if (mounted) setState(() => _views = v); } @override Widget build(BuildContext context) { final views = _views; return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('${widget.count}명을 어느 그룹에 넣을까요?', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), const SizedBox(height: 4), const Text('고르지 않으면 인맥에만 넣어요. 잠긴 그룹은 비밀번호로 연 뒤 고를 수 있어요.', style: TextStyle(fontSize: 12, color: Pal.muted)), const SizedBox(height: 10), if (views == null) const Center(child: CircularProgressIndicator()) else Wrap(spacing: 6, runSpacing: 6, children: [ for (final v in views) FilterChip( avatar: v.group.locked ? Icon(v.open ? Icons.lock_open : Icons.lock, size: 16) : null, label: Text(v.depth > 1 ? v.path : v.group.name), selected: _groups.contains(v.group.id), selectedColor: Pal.ofKind(v.group.kind).withValues(alpha: 0.3), onSelected: (on) async { if (on && !v.open) { if (!await openGroup(context, v)) return; await _reload(); } if (!mounted) return; setState(() => on ? _groups.add(v.group.id) : _groups.remove(v.group.id)); }, ), ]), const SizedBox(height: 8), ToggleRow( title: '친구 요청도 보내기', subtitle: '끄면 이 기기 인맥에만 넣고 상대에게는 아무것도 가지 않아요', value: _request, onChanged: (v) => setState(() => _request = v), ), const SizedBox(height: 8), SizedBox( width: double.infinity, height: 48, child: FilledButton(onPressed: () => Navigator.pop(context, _Options(_groups, _request)), child: const Text('가져오기')), ), ]), ), ); } }