Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
flutter-version: "3.44.0"
channel: stable
Expand Down
14 changes: 8 additions & 6 deletions .github/workflows/deploy-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,10 @@ jobs:
# Apple 은 iOS 26 SDK(Xcode 26+)로 빌드한 앱만 업로드 허용(2026-06-15~). macos-latest
# 가 아직 Xcode 16(iOS 18 SDK) 이미지라 409(SDK version) 가 나므로 macos-26 으로 고정.
runs-on: macos-26
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
# 최소권한: ASC_KEY_ID/ASC_ISSUER_ID 는 job env 로 전 스텝(서드파티 액션 포함)에 흘리지 않고
# 실제 쓰는 스텝(검증·키배치·빌드)의 step env 로만 좁힌다.
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0 # git rev-list 로 빌드번호(커밋 수)를 뽑으려면 전체 이력 필요

Expand All @@ -45,6 +44,8 @@ jobs:
APPLE_DIST_CERT_P12: ${{ secrets.APPLE_DIST_CERT_P12 }}
APPLE_DIST_CERT_PASSWORD: ${{ secrets.APPLE_DIST_CERT_PASSWORD }}
ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
run: |
missing=""
for v in SUPABASE_URL SUPABASE_ANON_KEY API_BASE_URL \
Expand All @@ -58,7 +59,7 @@ jobs:
exit 1
fi

- uses: subosito/flutter-action@v2
- uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
flutter-version: "3.44.0"
channel: stable
Expand All @@ -69,7 +70,7 @@ jobs:
# 배포 인증서(.p12)를 임시 keychain 에 import — 자동서명이 새 인증서를 만들지 않고
# 기존 것을 재사용하게 해(인증서 한도 소모 방지) CI 에서도 결정적으로 서명한다.
- name: Import Apple distribution certificate
uses: apple-actions/import-codesign-certs@v3
uses: apple-actions/import-codesign-certs@63fff01cd422d4b7b855d40ca1e9d34d2de9427d # v3
with:
p12-file-base64: ${{ secrets.APPLE_DIST_CERT_P12 }}
p12-password: ${{ secrets.APPLE_DIST_CERT_PASSWORD }}
Expand All @@ -78,6 +79,7 @@ jobs:
- name: Place App Store Connect API key
env:
ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
run: |
mkdir -p "$HOME/.appstoreconnect/private_keys"
KEY_PATH="$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8"
Expand Down
11 changes: 6 additions & 5 deletions .github/workflows/release-client.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
# 필요한 repo secrets(Settings → Secrets and variables → Actions):
# ANDROID_KEYSTORE_BASE64 = base64 < ieoseo-upload.jks
# ANDROID_KEYSTORE_PASSWORD = 키스토어 비밀번호(=키 비밀번호)
# ANDROID_KEY_ALIAS = upload
# PLAY_SERVICE_ACCOUNT_JSON = Google Play Console 서비스 계정 JSON(전체 내용)
# 참고: 키 alias 는 비밀이 아니라 고정값 `upload` 라 secret 이 아니라 아래 env 리터럴로 둔다
# (secret 으로 두면 흔한 단어 "upload" 가 로그 곳곳에서 과도 마스킹된다).
# SUPABASE_URL / SUPABASE_ANON_KEY / API_BASE_URL = 운영 dart-define 주입값(필수)
name: release-client

Expand All @@ -27,7 +28,7 @@ jobs:
HAS_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 != '' }}
HAS_PLAY: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON != '' }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

# 운영 dart-define 값이 비어 있으면 잘못된 서버를 가리키는 AAB 가 만들어져
# Play 에 그대로 올라갈 수 있다. 빌드 전에 빠르게 실패시켜 오배포를 막는다.
Expand All @@ -46,7 +47,7 @@ jobs:
exit 1
fi

- uses: subosito/flutter-action@v2
- uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
flutter-version: "3.44.0"
channel: stable
Expand All @@ -60,7 +61,7 @@ jobs:
env:
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_ALIAS: upload # 비밀 아님(고정 alias) — secret 과도 마스킹 회피
run: |
echo "$KEYSTORE_B64" | base64 --decode > "$RUNNER_TEMP/ieoseo-upload.jks"
cat > android/key.properties <<EOF
Expand Down Expand Up @@ -94,7 +95,7 @@ jobs:
# Play 내부테스트 업로드 — 서비스 계정 secret 있을 때만.
- name: Upload to Google Play (internal)
if: ${{ env.HAS_PLAY == 'true' }}
uses: r0adkll/upload-google-play@v1
uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: app.ieoseo
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ app.*.map.json
!.env.json.example
*.local
*.key
# CI(Fastfile)가 dart-define 값을 임시로 쓰는 파일 — 시크릿 포함, 커밋 금지(빌드 후 삭제됨)
ios/dart-define.prod.json
*.pem
*.p12
*.keystore
Expand Down
30 changes: 22 additions & 8 deletions ios/fastlane/Fastfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
# ASC API 키로 App Store 프로파일을 받아(sigh) 수동 서명 설정 → flutter build ipa → upload.
# 경로는 GITHUB_WORKSPACE(레포 루트=client) 기준 절대경로로 고정해 cwd 혼선을 막는다.

require "json"

default_platform(:ios)

WORKSPACE = ENV["GITHUB_WORKSPACE"] || File.expand_path("../..", __dir__)
Expand Down Expand Up @@ -62,14 +64,26 @@ platform :ios do

# 빌드번호는 App Store 단조 증가가 필요 → CI 가 커밋 수로 지정(중복 업로드 방지).
build_number = ENV.fetch("BUILD_NUMBER")
sh(
"cd #{WORKSPACE} && flutter build ipa --release " \
"--build-number=#{build_number} " \
"--export-options-plist=ios/ExportOptions.plist " \
"--dart-define=SUPABASE_URL=#{ENV.fetch('SUPABASE_URL')} " \
"--dart-define=SUPABASE_ANON_KEY=#{ENV.fetch('SUPABASE_ANON_KEY')} " \
"--dart-define=API_BASE_URL=#{ENV.fetch('API_BASE_URL')}"
)

# dart-define 값(SUPABASE_*/API_BASE_URL)을 명령줄에 직접 넣으면 fastlane sh() 가
# 확장된 전체 명령을 로그에 echo 하고 다른 프로세스의 /proc/<pid>/cmdline 에도 노출된다.
# 임시 JSON 파일 + --dart-define-from-file 로 빼서 시크릿이 명령줄에 안 실리게 한다(빌드 후 삭제).
define_file = "#{WORKSPACE}/ios/dart-define.prod.json"
File.write(define_file, JSON.generate(
"SUPABASE_URL" => ENV.fetch("SUPABASE_URL"),
"SUPABASE_ANON_KEY" => ENV.fetch("SUPABASE_ANON_KEY"),
"API_BASE_URL" => ENV.fetch("API_BASE_URL"),
))
begin
sh(
"cd #{WORKSPACE} && flutter build ipa --release " \
"--build-number=#{build_number} " \
"--export-options-plist=ios/ExportOptions.plist " \
"--dart-define-from-file=ios/dart-define.prod.json"
)
ensure
File.delete(define_file) if File.exist?(define_file)
end

ipa = Dir.glob("#{WORKSPACE}/build/ios/ipa/*.ipa").first
UI.user_error!("빌드된 .ipa 를 찾지 못했어요") if ipa.nil?
Expand Down
12 changes: 10 additions & 2 deletions lib/data/api/api_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,16 @@ class ApiRepository implements IeoseoRepository {
Future<void> deleteEvent(String id) => _client.delete('/events/$id');

@override
Future<DkEvent> pinEvent(DkEvent event, {required bool pinned}) =>
updateEvent(event.copyWith(pinned: pinned));
Future<DkEvent> completeEvent(String id) async {
final dynamic data = await _client.post('/events/$id/complete');
return DkEventDto.fromJson(_asMap(data));
}

@override
Future<DkEvent> reopenEvent(String id) async {
final dynamic data = await _client.post('/events/$id/reopen');
return DkEventDto.fromJson(_asMap(data));
}

// ── Tasks ───────────────────────────────────────────────
@override
Expand Down
1 change: 1 addition & 0 deletions lib/data/api/dtos.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ abstract final class DkEventDto {
pinned: json['pinned'] == true,
memo: _str(json['memo']) ?? '',
color: _str(json['color']) ?? 'cool',
completed: json['completed'] == true,
);

/// [DkEvent] → 생성 요청 본문(EventCreateRequest). id/파생계산은 보내지 않는다.
Expand Down
29 changes: 29 additions & 0 deletions lib/data/data_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,35 @@ class DataController extends ChangeNotifier {
}
}

/// 이벤트 종료(완료) 처리. 자동 삭제 대신 유저 명시 액션(FRD 5.1).
Future<DkEvent> completeEvent(String id) => _setEventCompleted(id, true);

/// 종료 취소(재개).
Future<DkEvent> reopenEvent(String id) => _setEventCompleted(id, false);

Future<DkEvent> _setEventCompleted(String id, bool completed) async {
final List<DkEvent> snapshot = _events;
// 낙관적 업데이트 후 server 응답으로 교체, 실패 시 롤백.
_events = _events
.map((DkEvent e) => e.id == id ? e.copyWith(completed: completed) : e)
.toList();
notifyListeners();
try {
final DkEvent updated = completed
? await _repo.completeEvent(id)
: await _repo.reopenEvent(id);
_events = _events
.map((DkEvent e) => e.id == updated.id ? updated : e)
.toList();
notifyListeners();
return updated;
} on Exception {
_events = snapshot;
notifyListeners();
rethrow;
}
}

// ── Debts ───────────────────────────────────────────────

Future<DkDebt> carryDebt(String id, {required String toDate}) async {
Expand Down
28 changes: 23 additions & 5 deletions lib/data/dday.dart
Original file line number Diff line number Diff line change
Expand Up @@ -90,17 +90,35 @@ DkDdayInfo ddayInfo(DkEvent ev, [DateTime? today]) {
case DkEventType.period:
final int toStart = daysBetween(t, ev.start!);
final int toEnd = daysBetween(t, ev.end!);
final String status = toStart > 0 ? '예정' : (toEnd >= 0 ? '진행중' : '종료');
final String label = toStart > 0
? '시작 D-$toStart'
: (toEnd > 0 ? '마감 D-$toEnd' : (toEnd == 0 ? '마감 D-DAY' : '종료'));
// 단계 전이: 시작 전 → 시작당일 → 진행중 → 마감당일 → 종료.
// 임박도(urgency)는 "지금 카운트다운 중인 경계" 기준으로 맞춘다 — 시작 전엔
// 시작까지, 시작 후엔 마감까지. 종료(D+)는 past 로 강조를 해제한다.
final String status;
final String label;
final DkUrgency urgency;
if (toStart >= 0) {
// 시작 전(toStart>0) 또는 시작 당일(toStart==0). 시작일까지 카운트다운.
status = toStart == 0 ? '진행중' : '예정';
label = toStart == 0 ? '시작 D-DAY' : '시작 D-$toStart';
urgency = urgencyOf(toStart);
} else if (toEnd >= 0) {
// 진행 중. 마감일까지 카운트다운(마감 당일 = "마감 D-DAY").
status = '진행중';
label = toEnd == 0 ? '마감 D-DAY' : '마감 D-$toEnd';
urgency = urgencyOf(toEnd);
} else {
// 종료: 마감일부터 경과(D+). 자동 삭제하지 않고 계속 노출(유저가 종료 처리).
status = '종료';
label = '마감 D+${-toEnd}';
urgency = DkUrgency.past;
}
return DkDdayInfo(
type: DkEventType.period,
toStart: toStart,
toEnd: toEnd,
status: status,
label: label,
urgency: urgencyOf(toEnd),
urgency: urgency,
);
}
}
1 change: 0 additions & 1 deletion lib/data/mock_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ List<DkEvent> get kEvents => <DkEvent>[
title: '정보처리기사 실기',
category: '자격증',
date: _d(28),
pinned: true,
memo: '필답형 + 실무. 기출 5개년 정리하기.',
color: 'blue',
remindDays: const <int>[7, 3, 1],
Expand Down
7 changes: 7 additions & 0 deletions lib/data/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ class DkEvent {
this.memo = '',
this.color = 'cool',
this.remindDays = const <int>[],
this.completed = false,
});

final String id;
Expand All @@ -209,6 +210,10 @@ class DkEvent {
final String color;
final List<int> remindDays;

/// 종료(완료) 처리 여부. true 면 홈 "다가오는 일정"에서 숨긴다(server `completed`).
/// 마감/기간이 지나도 자동 삭제하지 않고, 유저가 명시적으로 종료할 때만 true.
final bool completed;

/// nullable 날짜 필드(date/start/end)는 센티넬([_unset])로 받아 **null 로 비우기**도
/// 가능하게 한다(`field ?? this.field` 패턴은 null 설정을 못 함). 예: T1↔T2 전환 시 date 비우기.
DkEvent copyWith({
Expand All @@ -223,6 +228,7 @@ class DkEvent {
String? memo,
String? color,
List<int>? remindDays,
bool? completed,
}) {
return DkEvent(
id: id ?? this.id,
Expand All @@ -236,6 +242,7 @@ class DkEvent {
memo: memo ?? this.memo,
color: color ?? this.color,
remindDays: remindDays ?? this.remindDays,
completed: completed ?? this.completed,
);
}
}
Expand Down
22 changes: 18 additions & 4 deletions lib/data/repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,12 @@ abstract class IeoseoRepository {
/// 이벤트 삭제(server: DELETE /events/{id} → 204).
Future<void> deleteEvent(String id);

/// 홈 고정 토글. server 전용 엔드포인트가 없어 PUT 으로 `pinned` 만 갱신한다.
Future<DkEvent> pinEvent(DkEvent event, {required bool pinned});
/// 종료(완료) 처리(server: POST /events/{id}/complete). 갱신된 이벤트 반환.
/// 마감/기간이 지나도 자동 삭제하지 않고, 유저가 명시적으로 종료할 때 호출.
Future<DkEvent> completeEvent(String id);

/// 종료 취소(재개, server: POST /events/{id}/reopen).
Future<DkEvent> reopenEvent(String id);

// ── Tasks ───────────────────────────────────────────────
/// 태스크 목록(server: GET /tasks). [date] 지정 시 해당 일자만.
Expand Down Expand Up @@ -145,8 +149,18 @@ class MockRepository implements IeoseoRepository {
}

@override
Future<DkEvent> pinEvent(DkEvent event, {required bool pinned}) =>
updateEvent(event.copyWith(pinned: pinned));
Future<DkEvent> completeEvent(String id) async => _setCompleted(id, true);

@override
Future<DkEvent> reopenEvent(String id) async => _setCompleted(id, false);

DkEvent _setCompleted(String id, bool completed) {
final DkEvent updated = _events
.firstWhere((DkEvent e) => e.id == id)
.copyWith(completed: completed);
_events = _events.map((DkEvent e) => e.id == id ? updated : e).toList();
return updated;
}

@override
Future<List<DkTask>> tasks({String? date}) async {
Expand Down
Loading
Loading