티스토리 뷰
안녕하세요, 개발자 여러분! 이전 파트까지 계산기의 핵심 연산 및 공학 함수 기능을 구현했습니다. 이번 파트에서는 사용자의 편의성을 높이기 위해 간단한 사용자 설정 기능(삼각함수 각도 단위: 도/라디안)과 계산 히스토리 기능을 추가해 보겠습니다. 이 기능들은 사용자가 계산기를 자신에게 맞게 조정하고, 이전 계산을 쉽게 참조하거나 재사용할 수 있도록 도와줄 것입니다. 데이터 저장에는 shared_preferences 패키지를 사용합니다.
1. 이론: 사용자 설정과 히스토리 관리
1.1. 사용자 설정 (User Preferences)
- 필요성: 사용자가 앱의 특정 동작을 자신에게 맞게 변경할 수 있도록 하여 사용성을 높입니다. 예를 들어, 삼각함수 계산 시 각도 단위를 도(Degree)로 할지 라디안(Radian)으로 할지 선택하는 기능이 대표적입니다.
- 저장: 간단한 키-값 쌍의 사용자 설정은 shared_preferences 패키지를 사용하여 앱의 로컬 저장소에 쉽게 저장하고 불러올 수 있습니다. 이 패키지는 플랫폼(iOS, Android 등)의 네이티브 저장소(NSUserDefaults, SharedPreferences)를 활용합니다.
1.2. 계산 히스토리
- 필요성: 사용자가 이전에 수행했던 계산의 수식과 결과를 확인하고, 필요시 이를 다시 현재 계산에 활용할 수 있게 합니다.
- 데이터 구조: 히스토리 각 항목은 보통 '수식'과 '결과'를 한 쌍으로 가집니다. List<Map<String, String>> 형태(예: [{'expression': 'sin(30)', 'result': '0.5'}, ... ])로 관리할 수 있습니다.
- 저장: shared_preferences는 문자열 리스트 저장을 지원하므로, 히스토리 리스트를 JSON 문자열 형태로 변환하여 저장할 수 있습니다. 히스토리 양이 매우 많거나 복잡한 검색이 필요하다면 sqflite와 같은 로컬 데이터베이스를 고려할 수 있지만, 이번 파트에서는 shared_preferences를 사용한 간단한 구현에 집중합니다.
- 표시: 히스토리 목록은 AlertDialog, BottomSheet, 별도의 페이지 등을 통해 사용자에게 보여줄 수 있습니다.
1.3. 각도 단위 설정 (도/라디안)
- 삼각함수(sin, cos, tan) 계산 시 입력값의 단위를 사용자가 선택할 수 있도록 합니다.
- dart:math 라이브러리의 삼각함수는 라디안을 기본으로 사용하므로, '도' 모드일 경우 입력값을 라디안으로 변환(도 * (math.pi / 180.0))해 주어야 합니다.
2. 코딩: 설정 및 히스토리 기능 구현
2.1. shared_preferences 패키지 추가
pubspec.yaml 파일의 dependencies 섹션에 다음 라인을 추가합니다:
YAML
dependencies:
flutter:
sdk: flutter
shared_preferences: ^2.2.0 # 최신 버전으로 확인 후 추가
# ... 기존 패키지들 ...
intl: ^0.18.1 # (선택적) 히스토리 시간 표시 등에 사용 가능
터미널에서 flutter pub get을 실행하여 패키지를 설치합니다.
2.2. ExpressionEvaluator 수정 (각도 모드 지원)
ExpressionEvaluator가 각도 모드를 인자로 받아 삼각함수 계산 시 적용하도록 수정합니다.
Dart
// lib/services/expression_evaluator.dart
import 'dart:math' as math;
import 'package:developer_calculator/main.dart'; // RadixMode Enum
import 'package:developer_calculator/services/base_converter_service.dart';
class ExpressionEvaluator {
// ... (기존 _precedence, _functions, _hexChars, _baseConverter 등은 동일) ...
final Map<String, int> _precedence = {
'+': 1, '-': 1,
'&': 2, '^': 2, '|': 2,
'*': 3, '/': 3,
};
final List<String> _functions = ['SIN', 'COS', 'TAN', 'LOG', 'LN', 'SQRT', 'POW', 'FACT'];
final List<String> _hexChars = ['A', 'B', 'C', 'D', 'E', 'F'];
final BaseConverterService _baseConverter = BaseConverterService();
bool _isOperator(String s) { return _precedence.containsKey(s); }
bool _isFunction(String s) { return _functions.contains(s.toUpperCase());}
bool _isOperandChar(String char, RadixMode currentRadixMode) { /* ... 이전 파트와 동일 ... */ return false; } // 실제 구현 필요
List<String> tokenizeExpression(String infix, RadixMode currentRadixMode) { /* ... 이전 파트와 동일 ... */ return []; } // 실제 구현 필요
List<String> convertToPostfix(String infix, RadixMode currentRadixMode) { /* ... 이전 파트와 동일 ... */ return []; } // 실제 구현 필요
double _calculateFactorial(num n) { /* ... 이전 파트와 동일 ... */ return 0; } // 실제 구현 필요
// evaluatePostfix 메서드에 isDegreeMode 파라미터 추가
double evaluatePostfix(List<String> postfix, RadixMode currentRadixMode, bool isDegreeMode) {
List<num> evalStack = [];
for (String token in postfix) {
if (_isFunction(token)) {
num arg, arg2;
switch (token) {
case 'SIN':
case 'COS':
case 'TAN':
if (evalStack.isEmpty) throw FormatException("Insufficient arguments for $token");
arg = evalStack.removeLast();
double angle = arg.toDouble();
// isDegreeMode가 true이면 각도를 라디안으로 변환
if (isDegreeMode) {
angle = angle * (math.pi / 180.0);
}
if (token == 'SIN') evalStack.add(math.sin(angle));
else if (token == 'COS') evalStack.add(math.cos(angle));
else if (token == 'TAN') {
// tan(90도), tan(270도) 등 처리 (cos(angle)이 0에 가까울 때)
if ((math.cos(angle)).abs() < 1e-10) throw Exception("Tan undefined");
evalStack.add(math.tan(angle));
}
break;
// ... (LN, SQRT, FACT, LOG, POW 등 다른 함수 처리 로직은 이전 파트와 동일) ...
case 'LN':
if (evalStack.isEmpty) throw FormatException("Insufficient arguments for LN");
arg = evalStack.removeLast();
if (arg <= 0) throw FormatException("Logarithm of non-positive number");
evalStack.add(math.log(arg));
break;
case 'SQRT':
if (evalStack.isEmpty) throw FormatException("Insufficient arguments for SQRT");
arg = evalStack.removeLast();
if (arg < 0) throw FormatException("Square root of negative number");
evalStack.add(math.sqrt(arg));
break;
case 'FACT':
if (evalStack.isEmpty) throw FormatException("Insufficient arguments for FACT");
arg = evalStack.removeLast();
evalStack.add(_calculateFactorial(arg));
break;
case 'LOG':
if (evalStack.isEmpty) throw FormatException("Insufficient arguments for LOG");
arg = evalStack.removeLast();
if (arg <= 0) throw FormatException("Logarithm of non-positive number");
evalStack.add(math.log(arg) / math.ln10);
break;
case 'POW':
if (evalStack.length < 2) throw FormatException("Insufficient arguments for POW");
arg2 = evalStack.removeLast();
arg = evalStack.removeLast();
evalStack.add(math.pow(arg, arg2));
break;
default: throw FormatException("Unknown function: $token");
}
} else if (!_isOperator(token)) {
evalStack.add(_baseConverter.parseToDecimal(token, currentRadixMode));
} else {
// ... (이항/단항 연산자 처리 로직은 이전 파트와 동일) ...
// 이 부분은 실제 연산자 처리 로직으로 채워져야 합니다.
if (evalStack.length < 2) throw FormatException("Insufficient operands for operator $token");
num val2 = evalStack.removeLast();
num val1 = evalStack.removeLast();
num result;
// 예시: '+' 연산자만 처리
if (token == '+') {
result = val1 + val2;
} else {
throw FormatException("Unsupported operator: $token"); // 다른 연산자들 추가 필요
}
evalStack.add(result);
}
}
if (evalStack.length == 1) return evalStack.first.toDouble();
throw FormatException("Invalid expression - final stack error");
}
}
2.3. _CalculatorHomePageState 수정 (lib/main.dart)
설정 및 히스토리 관련 상태 변수, UI 요소, 로직을 추가합니다.
Dart
// lib/main.dart
import 'dart:convert'; // JSON 인코딩/디코딩을 위해
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; // SharedPreferences 임포트
// 서비스 클래스 임포트 (경로 확인)
import 'services/memory_manager.dart';
import 'services/base_converter_service.dart';
import 'services/expression_evaluator.dart';
// intl 패키지 (선택적: 히스토리 시간 포맷팅)
// import 'package:intl/intl.dart';
// ... (DeveloperCalculatorApp, CalculatorHomePage, RadixMode enum 등은 이전과 동일) ...
void main() { runApp(const DeveloperCalculatorApp()); }
class DeveloperCalculatorApp extends StatelessWidget {
const DeveloperCalculatorApp({super.key});
// ... build method ...
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Developer Calculator',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue, brightness: Brightness.dark), // 예시 색상
useMaterial3: true,
),
home: const CalculatorHomePage(),
);
}
}
class CalculatorHomePage extends StatefulWidget {
const CalculatorHomePage({super.key});
// ... createState ...
@override
State<CalculatorHomePage> createState() => _CalculatorHomePageState();
}
enum RadixMode { DEC, BIN, HEX }
class _CalculatorHomePageState extends State<CalculatorHomePage> {
String _expression = "";
String _output = "0";
bool _evaluated = false;
RadixMode _currentRadixMode = RadixMode.DEC;
int _currentValueForBaseConversion = 0;
final MemoryManager _memoryManager = MemoryManager();
final BaseConverterService _baseConverter = BaseConverterService();
final ExpressionEvaluator _evaluator = ExpressionEvaluator();
bool _isDegreeMode = false;
List<Map<String, String>> _historyList = [];
final int _maxHistorySize = 20;
final List<String> _hexChars = ['A', 'B', 'C', 'D', 'E', 'F'];
final List<String> _functionButtons = ['sin', 'cos', 'tan', 'log', 'ln', 'sqrt', 'x^y', 'n!'];
@override
void initState() {
super.initState();
_loadSettingsAndHistory();
}
Future<void> _loadSettingsAndHistory() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_isDegreeMode = prefs.getBool('isDegreeMode') ?? false;
List<String>? historyJson = prefs.getStringList('calculationHistory');
if (historyJson != null) {
_historyList = historyJson.map((item) => Map<String, String>.from(json.decode(item))).toList();
}
});
}
Future<void> _saveAngleModeSetting(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('isDegreeMode', value);
}
Future<void> _saveHistory() async {
final prefs = await SharedPreferences.getInstance();
List<String> historyJson = _historyList.map((item) => json.encode(item)).toList();
await prefs.setStringList('calculationHistory', historyJson);
}
void _addToHistory(String expression, String result) {
setState(() {
_historyList.insert(0, {'expression': expression, 'result': result});
if (_historyList.length > _maxHistorySize) {
_historyList.removeRange(_maxHistorySize, _historyList.length);
}
_saveHistory();
});
}
void _clearHistory() {
setState(() {
_historyList.clear();
_saveHistory();
});
Navigator.of(context).pop();
}
void _onButtonPressed(String buttonText) {
if (buttonText == "DEG" || buttonText == "RAD") {
setState(() {
_isDegreeMode = !_isDegreeMode;
_saveAngleModeSetting(_isDegreeMode);
});
return;
}
setState(() {
if (_isMemoryControl(buttonText)) { _handleMemoryButton(buttonText); return; }
if (_isRadixModeControl(buttonText)) { _handleChangeRadixMode(buttonText); return; }
String functionNameToAdd = buttonText.toLowerCase();
if (_functionButtons.contains(functionNameToAdd)) {
if (functionNameToAdd == "x^y") _appendToExpression("POW(");
else if (functionNameToAdd == "n!") _appendToExpression("FACT(");
else _appendToExpression("${functionNameToAdd.toUpperCase()}(");
_evaluated = false;
return;
}
if (_evaluated && !_evaluator._isOperator(buttonText) && buttonText != '.' && !_isRadixModeControl(buttonText) && !_isMemoryControl(buttonText) && !_functionButtons.contains(buttonText.toLowerCase())) {
if (buttonText != "(" && buttonText != ")") _expression = "";
_evaluated = false;
} else if (_evaluated && (_evaluator._isOperator(buttonText) || buttonText == '.')) {
_expression = _output;
_evaluated = false;
}
if (buttonText == "C") _clearAll();
else if (buttonText == "=") _calculateResult();
else if (buttonText == "⌫" || buttonText == "Del") _handleBackspace();
else if (_isValidInputForCurrentMode(buttonText)) _appendToExpression(buttonText);
});
}
void _calculateResult() {
if (_expression.isEmpty) return;
String originalExpressionOnError = _expression;
String expressionForHistory = _expression;
try {
String currentExpr = _expression;
if (currentExpr.isNotEmpty && _evaluator._isOperator(currentExpr[currentExpr.length - 1])) {
currentExpr = currentExpr.substring(0, currentExpr.length - 1);
}
List<String> postfixTokens = _evaluator.convertToPostfix(currentExpr, _currentRadixMode);
double decResult = _evaluator.evaluatePostfix(postfixTokens, _currentRadixMode, _isDegreeMode);
_currentValueForBaseConversion = decResult.toInt();
String resultStr = _baseConverter.formatFromDecimal(decResult, _currentRadixMode);
_output = resultStr;
_evaluated = true;
_addToHistory(expressionForHistory, resultStr);
} catch (e) {
_output = e is FormatException ? "Syntax Err"
: e.toString().contains("Division by zero") ? "DivByZero Err"
: e.toString().contains("Tan undefined") ? "Tan Undefined"
: "Error";
_expression = originalExpressionOnError;
_evaluated = true;
}
}
void _showHistoryDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text("Calculation History"),
contentPadding: const EdgeInsets.all(0),
content: SizedBox(
width: double.maxFinite,
child: _historyList.isEmpty
? const Center(child: Padding(padding: EdgeInsets.all(20.0), child: Text("No history yet.")))
: ListView.builder(
shrinkWrap: true,
itemCount: _historyList.length,
itemBuilder: (context, index) {
final item = _historyList[index];
return ListTile(
title: Text(item['expression']!, style: const TextStyle(fontSize: 16)),
subtitle: Text("= ${item['result']!}", style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
onTap: () {
setState(() {
_expression = item['expression']!;
_output = _expression;
_evaluated = false;
});
Navigator.of(context).pop();
},
onLongPress: () {
setState(() {
_expression = item['result']!;
_output = _expression;
_evaluated = false;
});
Navigator.of(context).pop();
},
);
},
),
),
actions: <Widget>[
TextButton(
child: const Text("Clear History"),
onPressed: _historyList.isEmpty ? null : _clearHistory,
),
TextButton(
child: const Text("Close"),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
}
Widget _buildButton(String buttonText, {int flex = 1, bool highlightMode = false, bool isToggleButton = false}) {
final ColorScheme colorScheme = Theme.of(context).colorScheme;
Color fgColor = colorScheme.onSurfaceVariant;
Color bgColor = colorScheme.surfaceVariant;
double fontSize = 18;
String actualButtonText = buttonText;
if (buttonText == "DEG/RAD") {
actualButtonText = _isDegreeMode ? "DEG" : "RAD";
if ((_isDegreeMode && actualButtonText == "DEG") || (!_isDegreeMode && actualButtonText == "RAD")){
// 현재 활성화된 모드를 강조 (예: DEG 모드일때 DEG 버튼, RAD 모드일때 RAD 버튼)
// 이 부분은 버튼 텍스트가 'DEG' 또는 'RAD'로 고정되고, _isDegreeMode에 따라 배경색을 바꾸는게 더 직관적일 수 있습니다.
// 여기서는 텍스트가 바뀌고, 해당 텍스트일 때 강조하는 방식
bgColor = colorScheme.primary; // 활성 모드 강조
fgColor = colorScheme.onPrimary;
}
} else if (_functionButtons.contains(buttonText.toLowerCase())) {
bgColor = colorScheme.tertiary;
fgColor = colorScheme.onTertiary;
fontSize = 14;
} else if (buttonText.length > 2 && !_isRadixModeControl(buttonText) && !_isMemoryControl(buttonText) && !_functionButtons.contains(buttonText.toLowerCase())) {
fontSize = 14;
} else if (highlightMode || (_memoryManager.isMemorySet && buttonText == "MR")) {
bgColor = colorScheme.primary;
fgColor = colorScheme.onPrimary;
} else if (["MC", "M+", "M-"].contains(buttonText)) {
bgColor = colorScheme.secondary;
fgColor = colorScheme.onSecondary;
} else if (buttonText == "C" || buttonText == "Del" || buttonText == "⌫") {
bgColor = colorScheme.errorContainer;
fgColor = colorScheme.onErrorContainer;
} else if (_evaluator._isOperator(buttonText) || buttonText == "(" || buttonText == ")" || buttonText == ",") {
bgColor = colorScheme.secondaryContainer;
fgColor = colorScheme.onSecondaryContainer;
} else if (buttonText == "=") {
bgColor = colorScheme.primaryContainer;
fgColor = colorScheme.onPrimaryContainer;
} else if (
(_hexChars.contains(buttonText.toUpperCase()) && _currentRadixMode == RadixMode.HEX) ||
RegExp(r'[0-9]').hasMatch(buttonText) ||
(buttonText == '.' && _currentRadixMode == RadixMode.DEC)
) {
bgColor = colorScheme.tertiaryContainer;
fgColor = colorScheme.onTertiaryContainer;
}
bool isEnabled = _isValidInputForCurrentMode(actualButtonText) || // 실제 버튼 텍스트로 유효성 검사
_evaluator._isOperator(actualButtonText) ||
_functionButtons.contains(actualButtonText.toLowerCase()) ||
["C", "⌫", "Del", "=", "(", ")", ","].contains(actualButtonText) ||
_isRadixModeControl(actualButtonText) ||
_isMemoryControl(actualButtonText) ||
buttonText == "DEG/RAD"; // DEG/RAD 버튼은 항상 활성화
if (!isEnabled && buttonText != "DEG/RAD"){
fgColor = colorScheme.onSurface.withOpacity(0.38);
}
return Expanded(
flex: flex,
child: Container(
margin: const EdgeInsets.all(3.0),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
foregroundColor: fgColor,
backgroundColor: bgColor,
padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 5.0),
textStyle: TextStyle(fontSize: fontSize, fontWeight: FontWeight.w500),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
).copyWith(
overlayColor: MaterialStateProperty.resolveWith<Color?>((Set<MaterialState> states) {
if (states.contains(MaterialState.pressed)) return colorScheme.onPrimaryContainer.withOpacity(0.12);
return null;
}),
),
onPressed: isEnabled ? () => _onButtonPressed(actualButtonText) : null, // 실제 버튼 텍스트로 이벤트 전달
child: Text(actualButtonText),
),
),
);
}
@override
Widget build(BuildContext context) {
final ColorScheme colorScheme = Theme.of(context).colorScheme;
String displayRadixMode = _currentRadixMode.toString().split('.').last;
String memoryIndicator = _memoryManager.isMemorySet ? " M" : "";
String angleIndicator = _isDegreeMode ? "DEG" : "RAD";
double outputFontSize = 36;
if (_output.length > 25) outputFontSize = 20;
else if (_output.length > 18) outputFontSize = 24;
else if (_output.length > 12) outputFontSize = 30;
return Scaffold(
appBar: AppBar(
title: Text('DevCalc ($displayRadixMode $angleIndicator$memoryIndicator)'),
actions: [
IconButton(
icon: const Icon(Icons.history),
tooltip: "History",
onPressed: _showHistoryDialog,
),
],
backgroundColor: colorScheme.inversePrimary,
),
backgroundColor: colorScheme.background,
body: Column(
children: <Widget>[
// 출력창, 표현식창 (이전과 동일)
Expanded( flex: 2, child: Container( /* ... _output 표시 ... */ ) ),
Padding( padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 4), child: Container( /* ... _expression 표시 ... */ ) ),
const Divider(height: 1, thickness: 1, indent: 4, endIndent: 4),
Padding( // 설정 버튼(DEG/RAD) 및 기본 모드 버튼 행
padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 2.0),
child: Row(children: <Widget>[
_buildButton("DEG/RAD", isToggleButton: true),
_buildButton("DEC", highlightMode: _currentRadixMode == RadixMode.DEC),
_buildButton("BIN", highlightMode: _currentRadixMode == RadixMode.BIN),
_buildButton("HEX", highlightMode: _currentRadixMode == RadixMode.HEX),
]),
),
const Divider(height: 1, thickness: 1, indent: 4, endIndent: 4),
// 나머지 버튼 행들 (Part 7 레이아웃 기반으로 함수 버튼, 메모리 버튼 등 배치)
// 예시: 함수 버튼 행
Expanded(child: Row(children: _functionButtons.sublist(0,5).map((f) => _buildButton(f)).toList())),
Expanded(child: Row(children: _functionButtons.sublist(5).map((f) => _buildButton(f)).toList() + [_buildButton("("), _buildButton(")"), _buildButton(",")])),
// ... (기타 숫자, 연산자, 메모리 버튼 행들 - Part 7 레이아웃 참조)
],
),
);
}
// 이전 파트의 헬퍼 메서드들 (구현 필요)
bool _isMemoryControl(String s){ return ["MC", "MR", "M+", "M-"].contains(s);}
bool _isRadixModeControl(String s){ return ["DEC", "BIN", "HEX"].contains(s);}
void _clearAll() {
_expression = ""; _output = "0"; _evaluated = false;
_currentValueForBaseConversion = 0; _memoryManager.clear();
}
void _handleBackspace(){
if (_expression.isNotEmpty) {
_expression = _expression.substring(0, _expression.length - 1);
_output = _expression.isEmpty ? "0" : _expression;
} else if (_output != "0") {
_output = "0"; _currentValueForBaseConversion = 0;
}
}
void _handleMemoryButton(String buttonText) { /* Part 6 로직 참고 */ }
void _handleChangeRadixMode(String modeText) { /* Part 6 로직 참고 */ }
bool _isValidInputForCurrentMode(String buttonText) {
if (_evaluator._isOperator(buttonText) || buttonText == '(' || buttonText == ')' || buttonText == ',') return true;
if (buttonText == '.') return _currentRadixMode == RadixMode.DEC;
if (RegExp(r'[0-9]').hasMatch(buttonText)) {
if (_currentRadixMode == RadixMode.BIN && (buttonText != '0' && buttonText != '1')) return false;
return true;
}
if (_hexChars.contains(buttonText.toUpperCase())) return _currentRadixMode == RadixMode.HEX;
return false;
}
// _appendToExpression은 위 _onButtonPressed 내부에 로직 통합 또는 별도 유지
}
2.4. 변경점 요약
- shared_preferences 통합:
- 패키지를 추가하고, initState에서 설정을 불러오며, 변경 시 저장하는 로직을 구현했습니다.
- 각도 모드(_isDegreeMode)와 계산 히스토리(_historyList)를 저장합니다.
- 각도 단위 설정:
- _isDegreeMode 상태 변수를 추가하고, UI에 "DEG/RAD" 토글 버튼을 통해 사용자가 변경할 수 있도록 했습니다.
- ExpressionEvaluator의 evaluatePostfix 메서드가 isDegreeMode 인자를 받아 삼각함수 계산 시 각도 단위를 올바르게 처리하도록 수정했습니다. tan 함수의 정의되지 않는 경우(예: tan(90도))에 대한 오류 처리도 추가했습니다.
- 계산 히스토리:
- _historyList 상태 변수를 추가하고, 계산 성공 시 _addToHistory를 통해 현재 수식과 결과를 저장합니다.
- _maxHistorySize를 두어 히스토리 개수를 제한합니다.
- _showHistoryDialog 함수를 통해 AlertDialog에 히스토리 목록을 표시하고, 항목 선택 시 해당 수식을 현재 입력창으로 가져올 수 있도록 했습니다. 히스토리 초기화 기능도 추가했습니다.
- UI 수정:
- AppBar에 히스토리 보기 아이콘 버튼과 현재 각도 모드 표시를 추가했습니다.
- 기존 버튼 레이아웃 상단에 "DEG/RAD" 토글 버튼을 추가했습니다.
- _buildButton에서 "DEG/RAD" 토글 버튼의 텍스트와 스타일을 동적으로 변경하도록 수정했습니다.
3. SOLID 원칙 고려사항
- SRP (단일 책임 원칙): _CalculatorHomePageState가 여전히 많은 역할을 하지만, shared_preferences 관련 로직(로드/저장)은 별도 private 메서드로 분리했습니다.
- 이상적으로는 설정 관리와 히스토리 관리를 각각 별도의 서비스 클래스(SettingsService, HistoryService)로 분리하여 _CalculatorHomePageState의 부담을 더욱 줄일 수 있습니다. 이는 추후 리팩토링 과제로 남겨둘 수 있습니다.
4. 디버깅 팁 💡
- SharedPreferences 값 확인: 개발 중 shared_preferences에 값이 제대로 저장되고 불러와지는지 확인하려면, 로드/저장 시점에 print로 값을 출력해보는 것이 간단합니다. 앱을 재시작했을 때 이전 설정/히스토리가 유지되는지 확인합니다.
- JSON 인코딩/디코딩 오류: 히스토리 저장 시 List<Map<String, String>>을 List<String> (JSON 문자열 리스트)으로 변환하고 다시 복원하는 과정에서 json.encode 및 json.decode 오류가 발생하지 않는지 확인합니다. try-catch를 사용하여 안정성을 높일 수 있습니다.
- 각도 모드 테스트: DEG 모드와 RAD 모드를 번갈아 가며 sin(90), cos(180) 등 잘 알려진 값을 입력하여 결과가 올바른지 (예: DEG 모드에서 sin(90) = 1, RAD 모드에서 sin(pi/2) = 1) 꼼꼼히 테스트합니다.
- 히스토리 기능 테스트: 히스토리 저장, 로드, 표시, 항목 선택, 초기화 기능이 모두 정상적으로 동작하는지, 특히 최대 개수 제한이 잘 적용되는지 확인합니다.
5. Q&A ❓
Q1: Flutter에서 간단한 사용자 설정을 저장하는 가장 일반적인 방법은 무엇인가요? A1: shared_preferences 패키지를 사용하는 것이 가장 일반적이고 간편한 방법입니다. 이 패키지는 소량의 키-값 데이터를 플랫폼의 영구 저장소(iOS의 NSUserDefaults, Android의 SharedPreferences)에 저장하고 불러올 수 있게 해줍니다. Boolean, integer, double, string, string list 타입의 데이터를 지원합니다.
Q2: 계산기 히스토리 목록은 어떻게 UI에 표시하고 관리할 수 있나요? A2: 히스토리 목록은 ListView.builder를 사용하여 스크롤 가능한 리스트로 표시하는 것이 효율적입니다. 이 리스트는 AlertDialog, BottomSheet, 또는 별도의 페이지(Route)에 배치할 수 있습니다. 각 히스토리 항목(ListTile 등)을 탭하면 해당 수식이나 결과를 현재 계산기 입력창으로 가져오는 기능을 추가할 수 있습니다. 히스토리 데이터는 List<Map<String, String>> 형태로 관리하고, shared_preferences에 저장 시에는 JSON 문자열 리스트로 변환합니다.
Q3: 삼각함수 계산 시 도(degree)와 라디안(radian) 모드를 전환하려면 어떤 점을 고려해야 하나요? A3: 1. 사용자 인터페이스: 사용자가 현재 어떤 모드인지 명확히 알 수 있도록 표시하고(예: "DEG" 또는 "RAD" 텍스트), 쉽게 모드를 전환할 수 있는 버튼이나 스위치를 제공해야 합니다. 2. 내부 계산 단위: dart:math의 삼각함수는 라디안을 사용하므로, '도' 모드일 경우 계산 전에 사용자 입력값을 라디안으로 변환해야 합니다 (도 * math.pi / 180.0). 3. 설정 저장: 선택된 모드는 shared_preferences 등을 사용하여 저장하여 앱을 다시 시작해도 유지되도록 해야 합니다. 4. 일관성: 계산기 전체에서 각도 단위가 일관되게 적용되도록 주의해야 합니다 (예: 역삼각함수의 결과 표시 등).
이번 파트에서는 shared_preferences를 활용하여 사용자 설정(각도 단위)과 계산 히스토리 기능을 구현했습니다. 이로써 계산기가 더욱 개인화되고 실용적인 도구로 발전했습니다. 다음 파트에서는 최종 UI/UX 다듬기, 아이콘 추가, 앱 테마 커스터마이징 등 애플리케이션의 완성도를 높이는 작업을 진행하거나, 고급 기능(예: 단위 변환, 상수)을 추가하는 것을 고려해볼 수 있습니다.
'개발 > Flutter' 카테고리의 다른 글
Flutter 개발자 계산기 만들기 - Part 10: 앱 아이콘, 스플래시 스크린 및 빌드/배포 준비 (0) | 2025.06.02 |
---|---|
Flutter 개발자 계산기 만들기 - Part 9: UI/UX 최종 다듬기 및 테마 커스터마이징 (0) | 2025.05.30 |
Flutter 개발자 계산기 만들기 - Part 7: 공학용 함수 추가 (삼각함수, 로그 등) (0) | 2025.05.29 |
Flutter 개발자 계산기 만들기 - Part 6: 코드 리팩토링 (로직 분리 및 가독성 향상) (0) | 2025.05.28 |
Flutter 개발자 계산기 만들기 - Part 5: 메모리 기능 및 UI/UX 개선 (0) | 2025.05.28 |