252 lines
9.7 KiB
Dart
252 lines
9.7 KiB
Dart
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<ContactImportScreen> createState() => _ContactImportScreenState();
|
|
}
|
|
|
|
class _ContactImportScreenState extends State<ContactImportScreen> {
|
|
List<ContactCandidate>? _all;
|
|
bool _denied = false;
|
|
String _query = '';
|
|
String? _label;
|
|
bool _hideKnown = false;
|
|
final Set<String> _picked = {}; // 주소록 번호표
|
|
bool _busy = false;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
if (_all == null && !_busy) _load();
|
|
}
|
|
|
|
Future<void> _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 <ContactCandidate>[];
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_busy = false;
|
|
_denied = !allowed;
|
|
_all = list;
|
|
_picked.removeWhere((id) => !list.any((c) => c.contact.id == id));
|
|
});
|
|
}
|
|
|
|
List<ContactCandidate> get _shown =>
|
|
ContactImportService.filter(_all ?? const [], ContactFilter(query: _query, label: _label, hideKnown: _hideKnown));
|
|
|
|
Future<void> _import() async {
|
|
final app = AppScope.of(context).app;
|
|
final picked = [for (final c in _all ?? const <ContactCandidate>[]) 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<ContactCandidate> 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<String> 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<String> _groups = {if (widget.preset != null) widget.preset!};
|
|
bool _request = false;
|
|
List<GroupView>? _views;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_views ?? _reload();
|
|
}
|
|
|
|
Future<void> _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('가져오기')),
|
|
),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|