Skip to content

Commit 15a668b

Browse files
committed
feat: AtCoderレーティング情報の取得機能を追加し、レート表示を改善
refactor: 不要なインポートを削除 fix: 表示用レートの計算ロジックを修正
1 parent 3f76d92 commit 15a668b

File tree

8 files changed

+165
-19
lines changed

8 files changed

+165
-19
lines changed

lib/main.dart

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
33
import 'package:dynamic_color/dynamic_color.dart';
44
import 'package:google_fonts/google_fonts.dart';
55
import 'package:provider/provider.dart';
6-
import 'package:animations/animations.dart';
76
import 'screens/problem_detail_screen.dart';
87
import 'screens/editor_screen.dart';
98
import 'screens/settings_screen.dart';
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class AtcoderRatingInfo {
2+
final int latestRating;
3+
final int contestCount;
4+
5+
const AtcoderRatingInfo({
6+
required this.latestRating,
7+
required this.contestCount,
8+
});
9+
}

lib/models/contest.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,11 @@ class Contest {
6969
final hours = durationMin ~/ 60;
7070
final minutes = durationMin % 60;
7171
if (hours == 0) {
72-
return '${minutes}分';
72+
return '$minutes';
7373
} else if (minutes == 0) {
74-
return '${hours}時間';
74+
return '$hours時間';
7575
} else {
76-
return '${hours}時間${minutes}分';
76+
return '$hours時間$minutes';
7777
}
7878
}
7979
}

lib/screens/problem_detail_screen.dart

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import 'package:flutter/material.dart';
22
import 'package:flutter/services.dart';
33
import 'dart:developer' as developer;
4-
import 'package:google_fonts/google_fonts.dart';
54
import 'package:provider/provider.dart';
65
import '../models/problem.dart';
76
import '../services/atcoder_service.dart';

lib/screens/recommend_screen.dart

Lines changed: 70 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import 'package:shared_preferences/shared_preferences.dart';
33
import '../models/problem_difficulty.dart';
44
import '../services/atcoder_service.dart';
55
import 'problem_detail_screen.dart';
6+
import '../utils/atcoder_colors.dart';
7+
import '../utils/rating_utils.dart';
68

79
class RecommendScreen extends StatefulWidget {
810
const RecommendScreen({super.key});
@@ -20,7 +22,7 @@ class _RecommendScreenState extends State<RecommendScreen> {
2022
bool _isLoading = false;
2123
String? _errorMessage;
2224
String? _savedUsername; // 設定済みユーザー名
23-
int? _currentRating; // 取得したレート
25+
int? _currentRating; // 取得したレート(表示用: 最新レート)
2426

2527
// AtCoder カラー判定
2628
Color _ratingColor(int rating) {
@@ -62,6 +64,43 @@ class _RecommendScreenState extends State<RecommendScreen> {
6264
);
6365
}
6466

67+
// Difficulty 表示用バッジ(補正後diffで表示。色も補正後ベース)
68+
Widget _difficultyBadge(int? difficulty) {
69+
int? mappedInt;
70+
if (difficulty != null) {
71+
final mapped = difficulty <= 400
72+
? RatingUtils.mapRating(difficulty)
73+
: difficulty.toDouble();
74+
mappedInt = mapped.round();
75+
}
76+
final color = (mappedInt != null)
77+
? atcoderRatingToColor(mappedInt)
78+
: const Color(0xFF808080);
79+
return Container(
80+
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
81+
decoration: BoxDecoration(
82+
color: color.withOpacity(0.12),
83+
border: Border.all(color: color, width: 1),
84+
borderRadius: BorderRadius.circular(999),
85+
),
86+
child: Row(
87+
mainAxisSize: MainAxisSize.min,
88+
children: [
89+
Icon(Icons.bolt, size: 14, color: color),
90+
const SizedBox(width: 6),
91+
Text(
92+
mappedInt?.toString() ?? 'N/A',
93+
style: TextStyle(
94+
color: color,
95+
fontSize: 14,
96+
fontWeight: FontWeight.w700,
97+
),
98+
),
99+
],
100+
),
101+
);
102+
}
103+
65104
@override
66105
void initState() {
67106
super.initState();
@@ -131,31 +170,44 @@ class _RecommendScreenState extends State<RecommendScreen> {
131170
throw Exception('ユーザー名を入力してください');
132171
}
133172

134-
final rating = await _atcoderService.fetchAtCoderRate(username);
135-
if (rating == null) {
173+
final ratingInfo = await _atcoderService.fetchAtcoderRatingInfo(username);
174+
if (ratingInfo == null) {
136175
throw Exception('ユーザーが見つからないか、レーティングがありません');
137176
}
138177

139178
// レートを保存してUIに表示
140179
if (mounted) {
141180
setState(() {
142-
_currentRating = rating;
181+
_currentRating = ratingInfo.latestRating;
143182
});
144183
}
145184

146185
final allProblems = await _atcoderService.fetchProblemDifficulties();
186+
// TrueRating を計算(数式(10))
187+
final trueRating = RatingUtils.trueRating(
188+
rating: ratingInfo.latestRating,
189+
contests: ratingInfo.contestCount,
190+
);
147191

148192
final recommended = allProblems.entries.where((entry) {
149193
final difficulty = entry.value.difficulty;
150-
return difficulty != null &&
151-
difficulty >= rating + lowerDelta &&
152-
difficulty <= rating + upperDelta;
194+
if (difficulty == null) return false;
195+
// 400 以下の diff は mapRating で補正(比較用のみ)
196+
final mappedDiff = difficulty <= 400
197+
? RatingUtils.mapRating(difficulty)
198+
: difficulty.toDouble();
199+
return mappedDiff >= trueRating + lowerDelta &&
200+
mappedDiff <= trueRating + upperDelta;
153201
}).toList();
154202

155203
// 自分のレートに近い順に並べ替え(差の絶対値の昇順)
156204
recommended.sort((a, b) {
157-
final da = (a.value.difficulty! - rating).abs();
158-
final db = (b.value.difficulty! - rating).abs();
205+
final ad = a.value.difficulty!;
206+
final bd = b.value.difficulty!;
207+
final mad = ad <= 400 ? RatingUtils.mapRating(ad) : ad.toDouble();
208+
final mbd = bd <= 400 ? RatingUtils.mapRating(bd) : bd.toDouble();
209+
final da = (mad - trueRating).abs();
210+
final db = (mbd - trueRating).abs();
159211
final cmp = da.compareTo(db);
160212
if (cmp != 0) return cmp;
161213
// 差が同じ場合は難易度の昇順で安定化
@@ -275,13 +327,18 @@ class _RecommendScreenState extends State<RecommendScreen> {
275327
itemCount: _recommendedProblems.length,
276328
itemBuilder: (context, index) {
277329
final problem = _recommendedProblems[index];
330+
final diff = problem.value.difficulty;
278331
return ListTile(
279332
title: Text(problem.key),
280-
subtitle: Text(
281-
'Difficulty: ${problem.value.difficulty ?? "なし"}'),
282-
trailing: const Icon(Icons.open_in_new),
333+
trailing: Row(
334+
mainAxisSize: MainAxisSize.min,
335+
children: [
336+
_difficultyBadge(diff),
337+
const SizedBox(width: 8),
338+
const Icon(Icons.open_in_new),
339+
],
340+
),
283341
onTap: () {
284-
// 問題詳細へ遷移(WebView連携と同様にIDからページを開く)
285342
Navigator.push(
286343
context,
287344
MaterialPageRoute(

lib/services/atcoder_service.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'package:http/http.dart' as http;
33
import 'package:html/parser.dart' as parser;
44
import 'package:html/dom.dart';
55
import '../models/atcoder_user_history.dart';
6+
import '../models/atcoder_rating_info.dart';
67
import '../models/problem.dart';
78
import '../models/problem_difficulty.dart';
89
import 'dart:developer' as developer;
@@ -26,6 +27,28 @@ class AtCoderService {
2627
}
2728
}
2829

30+
Future<AtcoderRatingInfo?> fetchAtcoderRatingInfo(String name) async {
31+
final url = Uri.parse('https://atcoder.jp/users/$name/history/json');
32+
final response = await http.get(url);
33+
34+
if (response.statusCode == 200) {
35+
final List<dynamic> data = jsonDecode(response.body);
36+
if (data.isEmpty) {
37+
return null;
38+
}
39+
final history =
40+
data.map((item) => AtCoderUserHistory.fromJson(item)).toList();
41+
if (history.isEmpty) return null;
42+
history.sort((a, b) => b.endTime.compareTo(a.endTime));
43+
final latest = history.first.newRating;
44+
// Count only rated contests
45+
final ratedCount = history.where((h) => h.isRated).length;
46+
return AtcoderRatingInfo(latestRating: latest, contestCount: ratedCount);
47+
} else {
48+
throw Exception('Failed to load user history');
49+
}
50+
}
51+
2952
Future<Map<String, ProblemDifficulty>> fetchProblemDifficulties() async {
3053
final url =
3154
Uri.parse('https://kenkoooo.com/atcoder/resources/problem-models.json');

lib/utils/rating_utils.dart

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import 'dart:math' as math;
2+
3+
/// Rating utilities implementing AtCoder-like supplemental formulas.
4+
///
5+
/// Formulas (from user's spec):
6+
/// mapRating(r) = r if r > 400
7+
/// = 400 / exp((400 - r)/400) if r <= 400
8+
///
9+
/// F(n) = sqrt(sum_{i=1..n} 0.81^i) / (sum_{i=1..n} 0.9^i)
10+
/// f(n) = (F(n) - F(infty)) / (F(1) - F(infty)) * 1200
11+
///
12+
/// TrueRating = mapRating(Rating - f(n))
13+
class RatingUtils {
14+
RatingUtils._();
15+
16+
/// Maps ratings (or difficulties) below or equal to 400
17+
/// to a positive scale per the provided exponential mapping.
18+
/// Returns a double to keep precision; callers can round if needed.
19+
static double mapRating(num r) {
20+
final rr = r.toDouble();
21+
if (rr > 400.0) return rr;
22+
return 400.0 / math.exp((400.0 - rr) / 400.0);
23+
}
24+
25+
/// Computes F(n) using closed-form geometric sums.
26+
/// sum_{i=1..n} a^i = a * (1 - a^n) / (1 - a)
27+
static double _F(int n) {
28+
if (n <= 0) return 0.0;
29+
const a1 = 0.81; // 0.9^2
30+
const a2 = 0.9;
31+
final sum1 = a1 * (1 - math.pow(a1, n)) / (1 - a1);
32+
final sum2 = a2 * (1 - math.pow(a2, n)) / (1 - a2);
33+
return math.sqrt(sum1) / sum2;
34+
}
35+
36+
/// F(infty) using closed-form limits of geometric sums.
37+
static double _FInf() {
38+
const a1 = 0.81;
39+
const a2 = 0.9;
40+
final sum1Inf = a1 / (1 - a1);
41+
final sum2Inf = a2 / (1 - a2);
42+
return math.sqrt(sum1Inf) / sum2Inf;
43+
}
44+
45+
/// f(n) per the provided formula.
46+
static double f(int n) {
47+
if (n <= 0) return 1200.0; // safe fallback; not expected in practice
48+
final fn = _F(n);
49+
final finf = _FInf();
50+
final f1 = _F(1);
51+
// Avoid division by a tiny number; clamp if needed
52+
final denom = (f1 - finf).abs() < 1e-12 ? 1e-12 : (f1 - finf);
53+
return ((fn - finf) / denom) * 1200.0;
54+
}
55+
56+
/// Computes TrueRating = mapRating(Rating - f(n)).
57+
static double trueRating({required num rating, required int contests}) {
58+
return mapRating(rating - f(contests));
59+
}
60+
}

test/widget_test.dart

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import 'package:flutter/material.dart';
21
import 'package:flutter_test/flutter_test.dart';
32
import 'package:provider/provider.dart';
43
import 'package:shojin_app/main.dart';

0 commit comments

Comments
 (0)