티스토리 뷰

반응형

사용자 설정 및 히스토리 기능 추가

안녕하세요, 개발자 여러분! 이전 파트까지 계산기의 핵심 연산 및 공학 함수 기능을 구현했습니다. 이번 파트에서는 사용자의 편의성을 높이기 위해 간단한 사용자 설정 기능(삼각함수 각도 단위: 도/라디안)과 계산 히스토리 기능을 추가해 보겠습니다. 이 기능들은 사용자가 계산기를 자신에게 맞게 조정하고, 이전 계산을 쉽게 참조하거나 재사용할 수 있도록 도와줄 것입니다. 데이터 저장에는 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 원칙 고려사항

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 ❓

Q&amp;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 다듬기, 아이콘 추가, 앱 테마 커스터마이징 등 애플리케이션의 완성도를 높이는 작업을 진행하거나, 고급 기능(예: 단위 변환, 상수)을 추가하는 것을 고려해볼 수 있습니다.