import 'package:flutter/material.dart'; import '../core/cron_window.dart'; import '../l10n/tr.dart'; import 'theme.dart'; import 'jj_field.dart'; /// 요일 이름 (0 = 일요일) List weekdayNames() => [tr('일'), tr('월'), tr('화'), tr('수'), tr('목'), tr('금'), tr('토')]; /// 일정 요약: "계속" · cron 줄 · 지금 상태 (동작 중 ~ 끝 / 다음 시작) String scheduleSummary(List lines, [DateTime? now]) { final t = now ?? DateTime.now(); if (lines.isEmpty) return tr('계속 (앱이 켜져 있는 동안)'); String hm(DateTime d) => '${weekdayNames()[d.weekday % 7]} ${d.hour.toString().padLeft(2, '0')}:00'; final end = scheduleEnd(lines, t); if (scheduleActive(lines, t)) return trf('동작 중 · {0} 까지', [end == null ? '…' : hm(end)]); final next = scheduleNextStart(lines, t); return trf('대기 · 다음 시작 {0}', [next == null ? '-' : hm(next)]); } /// 일정 편집: 계속 / 시간 지정 (요일 × 시 격자를 누르거나 끌어서 칠하기, cron 글로도). 취소면 null. Future?> editSchedule(BuildContext context, List initial) => showDialog>(context: context, builder: (_) => _ScheduleDialog(initial: initial)); class _ScheduleDialog extends StatefulWidget { final List initial; const _ScheduleDialog({required this.initial}); @override State<_ScheduleDialog> createState() => _ScheduleDialogState(); } class _ScheduleDialogState extends State<_ScheduleDialog> { late bool _always = widget.initial.isEmpty; late Set _cells = widget.initial.isEmpty ? {for (var d = 1; d <= 5; d++) for (var h = 9; h < 18; h++) d * 24 + h} : gridFromCron(widget.initial); /// 격자로 나타낼 수 없는 줄 (일 · 월을 정한 cron) 은 글로만 둔다 late List _extra = [for (final l in widget.initial) if (!(CronExpr.tryParse(l)?.gridable ?? false)) l]; late final _text = TextEditingController(text: _lines.join('\n')); String? _error; List get _lines => [...cronFromGrid(_cells), ..._extra]; void _syncText() => _text.text = _lines.join('\n'); void _set(Set cells) { setState(() { _cells = cells; _error = null; }); _syncText(); } void _applyText() { final lines = _text.text.split('\n').map((l) => l.trim()).where((l) => l.isNotEmpty).toList(); for (final l in lines) { try { CronExpr.parse(l); } on FormatException catch (e) { setState(() => _error = '${e.message}: $l'); return; } } setState(() { _cells = gridFromCron(lines); _extra = [for (final l in lines) if (!CronExpr.parse(l).gridable) l]; _error = null; }); } @override void dispose() { _text.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final lines = _lines; return AlertDialog( title: Text(tr('동기화 시간')), content: SizedBox( width: 760, child: SingleChildScrollView( child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap(spacing: 8, children: [ ChoiceChip( label: Text(tr('계속')), selected: _always, onSelected: (_) => setState(() => _always = true), ), ChoiceChip( label: Text(tr('시간 지정 (cron)')), selected: !_always, onSelected: (_) => setState(() => _always = false), ), ]), const SizedBox(height: 8), Text( _always ? tr('앱이 켜져 있는 동안 늘 동기화합니다.') : tr('칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. ' '칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).'), style: TextStyle(fontSize: 12, color: JjColors.textDim), ), if (!_always) ...[ const SizedBox(height: 10), _WeekGrid(cells: _cells, onChanged: _set), const SizedBox(height: 8), Wrap(spacing: 6, runSpacing: 6, children: [ for (final (label, cells) in [ (tr('평일 9~18시'), {for (var d = 1; d <= 5; d++) for (var h = 9; h < 18; h++) d * 24 + h}), (tr('밤 0~6시'), {for (var d = 0; d < 7; d++) for (var h = 0; h < 6; h++) d * 24 + h}), (tr('주말 종일'), {for (final d in [0, 6]) for (var h = 0; h < 24; h++) d * 24 + h}), (tr('매일 종일'), {for (var i = 0; i < 7 * 24; i++) i}), (tr('모두 지우기'), {}), ]) ActionChip(label: Text(label), onPressed: () => _set(cells)), ]), const SizedBox(height: 12), Text(tr('cron (분 시 일 월 요일) · 한 줄에 하나'), style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 4), JjField( controller: _text, minLines: 2, maxLines: 5, style: const TextStyle(fontFamily: 'monospace', fontSize: 13), isDense: true, outlined: true, errorText: _error, helper: tr('예: 0 9-17 * * 1-5 (평일 9시~17시 59분) · 0 0-5 * * * (매일 0~5시). 분은 무시하고 1시간 단위'), suffix: IconButton(tooltip: tr('글을 격자에 반영'), icon: const Icon(Icons.check), onPressed: _applyText), onSubmitted: (_) => _applyText(), ), const SizedBox(height: 8), Text(lines.isEmpty ? tr('칠한 시간이 없어 동기화하지 않습니다.') : scheduleSummary(lines), style: TextStyle(fontSize: 12, color: JjColors.accent)), ], ]), ), ), actions: [ TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))), FilledButton( onPressed: _error != null ? null : () => Navigator.pop(context, _always ? const [] : lines), child: Text(tr('저장')), ), ], ); } } /// 요일 (세로 7) × 시 (가로 24) 격자. 누르면 켜기 · 끄기, 끌면 첫 칸의 반대로 칠하기. class _WeekGrid extends StatefulWidget { final Set cells; final ValueChanged> onChanged; const _WeekGrid({required this.cells, required this.onChanged}); @override State<_WeekGrid> createState() => _WeekGridState(); } class _WeekGridState extends State<_WeekGrid> { static const _label = 34.0, _head = 18.0, _rowH = 26.0; bool? _paint; Set _work = {}; int? _cellAt(Offset pos, double cellW) { final x = pos.dx - _label, y = pos.dy - _head; if (x < 0 || y < 0) return null; final h = (x / cellW).floor(), d = (y / _rowH).floor(); if (h < 0 || h > 23 || d < 0 || d > 6) return null; return d * 24 + h; } void _toggleRow(int d) { final all = [for (var h = 0; h < 24; h++) d * 24 + h]; final on = all.every(widget.cells.contains); widget.onChanged({...widget.cells}..removeAll(all)..addAll(on ? const [] : all)); } void _toggleCol(int h) { final all = [for (var d = 0; d < 7; d++) d * 24 + h]; final on = all.every(widget.cells.contains); widget.onChanged({...widget.cells}..removeAll(all)..addAll(on ? const [] : all)); } @override Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) { final cellW = (box.maxWidth - _label) / 24; final names = weekdayNames(); void paintAt(Offset pos) { final i = _cellAt(pos, cellW); if (i == null || _paint == null) return; final next = {..._work}; _paint! ? next.add(i) : next.remove(i); if (next.length != _work.length) { _work = next; widget.onChanged(next); } } return SizedBox( height: _head + _rowH * 7, child: Stack(children: [ // 시 숫자 (누르면 그 시간 전체 요일) for (var h = 0; h < 24; h++) Positioned( left: _label + h * cellW, top: 0, width: cellW, height: _head, child: InkWell( onTap: () => _toggleCol(h), child: Center( child: Text(h % 3 == 0 ? '$h' : '', style: TextStyle(fontSize: 10, color: JjColors.textDim)), ), ), ), // 요일 이름 (누르면 그 요일 전체) for (var d = 0; d < 7; d++) Positioned( left: 0, top: _head + d * _rowH, width: _label, height: _rowH, child: InkWell( onTap: () => _toggleRow(d), child: Center( child: Text(names[d], style: TextStyle(fontSize: 12, color: d == 0 ? JjColors.alert : d == 6 ? Colors.lightBlueAccent : null)), ), ), ), Positioned( left: _label, top: _head, right: 0, height: _rowH * 7, child: GestureDetector( behavior: HitTestBehavior.opaque, onTapUp: (e) { final i = _cellAt(e.localPosition + const Offset(_label, _head), cellW); if (i == null) return; final next = {...widget.cells}; next.contains(i) ? next.remove(i) : next.add(i); widget.onChanged(next); }, onPanStart: (e) { final i = _cellAt(e.localPosition + const Offset(_label, _head), cellW); _work = {...widget.cells}; _paint = i == null ? null : !widget.cells.contains(i); paintAt(e.localPosition + const Offset(_label, _head)); }, onPanUpdate: (e) => paintAt(e.localPosition + const Offset(_label, _head)), onPanEnd: (_) => _paint = null, child: CustomPaint(painter: _GridPainter(widget.cells, cellW, _rowH)), ), ), ]), ); }); } class _GridPainter extends CustomPainter { final Set cells; final double cellW, rowH; _GridPainter(this.cells, this.cellW, this.rowH); @override void paint(Canvas canvas, Size size) { final on = Paint()..color = JjColors.accent; final off = Paint()..color = JjColors.border.withValues(alpha: 0.45); for (var d = 0; d < 7; d++) { for (var h = 0; h < 24; h++) { final r = Rect.fromLTWH(h * cellW + 1, d * rowH + 1, cellW - 2, rowH - 2); canvas.drawRRect(RRect.fromRectAndRadius(r, const Radius.circular(3)), cells.contains(d * 24 + h) ? on : off); } } } @override bool shouldRepaint(_GridPainter old) => old.cells != cells || old.cellW != cellW; } /// 읽기 전용 작은 격자 (모니터링 · 설정의 목록에서 일정 한눈에) class WeekGridPreview extends StatelessWidget { final List lines; final double width; const WeekGridPreview({super.key, required this.lines, this.width = 168}); @override Widget build(BuildContext context) { final cells = lines.isEmpty ? {for (var i = 0; i < 168; i++) i} : gridFromCron(lines); final cellW = width / 24; return SizedBox( width: width, height: cellW * 7 * 1.2, child: CustomPaint(painter: _GridPainter(cells, cellW, cellW * 1.2)), ); } }