ソースコード source code

下記アプリの主要なソースコードを公開しています。アプリ開発の参考になれば幸いです。

画像等が別途必要ですので下記情報のみでアプリが完成するものではありません。 アプリは少しずつ機能拡張していますのでストア公開されているアプリと内容が異なる場合があります。 コードはコピーして自由にお使いいただけます。ただし著作権は放棄しておりませんので全部の再掲載はご遠慮ください。部分的に再掲載したり、改変して再掲載するのは構いません。 自身のアプリ作成の参考として個人使用・商用問わず自由にお使いいただけます。 コード記述のお手本を示すものではありません。ミニアプリですので変数名などさほど気遣いしていない部分も有りますし間違いも有るかと思いますので参考程度にお考え下さい。 他の賢者の皆様が公開されているコードを参考にした箇所も含まれます。Androidアプリ開発の熟練者が書いたコードではありません。 エンジニア向け技術情報共有サービスではありませんので説明は省いています。ご了承ください。 GitHubなどへの公開は予定しておりません。

下記コードの最終ビルド日: 2025-09-23

pubspec.yaml

name: basalmetabolism
description: "BasalMetabolism"
publish_to: 'none'

version: 2.0.0+13

environment:
  sdk: ">=3.3.0 <4.0.0"

dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
  shared_preferences: ^2.3.2
  cupertino_icons: ^1.0.8
  google_mobile_ads: ^6.0.0


dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^6.0.0
  flutter_launcher_icons: ^0.14.4    #flutter pub run flutter_launcher_icons
  flutter_native_splash: ^2.4.0     #flutter pub run flutter_native_splash:create

flutter_icons:
  android: "launcher_icon"
  ios: true
  image_path: "assets/icon/icon.png"
  adaptive_icon_background: "assets/icon/icon_back.png"
  adaptive_icon_foreground: "assets/icon/icon_fore.png"

flutter_native_splash:
  color: '#9da9f5'
  image: 'assets/image/splash.png'
  color_dark: '#9da9f5'
  image_dark: 'assets/image/splash.png'
  fullscreen: true
  android_12:
    icon_background_color: '#9da9f5'
    image: 'assets/image/splash.png'
    icon_background_color_dark: '#9da9f5'
    image_dark: 'assets/image/splash.png'

flutter:
  uses-material-design: true

  assets:
    - assets/icon/
    - assets/image/

lib/ad_banner_widget.dart

import 'package:flutter/cupertino.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';

import 'package:basalmetabolism/ad_manager.dart';

class AdBannerWidget extends StatefulWidget {
  final AdManager adManager;
  const AdBannerWidget({super.key, required this.adManager});
  @override
  State<AdBannerWidget> createState() => _AdBannerWidgetState();
}

class _AdBannerWidgetState extends State<AdBannerWidget> {
  int _lastBannerWidthDp = 0;
  bool _isAdLoaded = false;
  bool _isLoading = false;
  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: LayoutBuilder(
        builder: (context, constraints) {
          final int width = constraints.maxWidth.isFinite ? constraints.maxWidth.truncate() : MediaQuery.of(context).size.width.truncate();
          final bannerAd = widget.adManager.bannerAd;
          if (width > 0) {
            WidgetsBinding.instance.addPostFrameCallback((_) {
              if (mounted) {
                final bannerAd = widget.adManager.bannerAd;
                final bool widthChanged = _lastBannerWidthDp != width;
                final bool sizeMismatch = bannerAd == null || bannerAd.size.width != width;
                if ((widthChanged || !_isAdLoaded || sizeMismatch) && !_isLoading) {
                  _lastBannerWidthDp = width;
                  setState(() { _isAdLoaded = false; _isLoading = true; });
                  widget.adManager.loadAdaptiveBannerAd(width, () {
                    if (mounted) {
                      setState(() { _isAdLoaded = true; _isLoading = false; });
                    }
                  });
                }
              }
            });
          }
          if (_isAdLoaded && bannerAd != null) {
            return Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  SizedBox(height: 10),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      SizedBox(
                        width: bannerAd.size.width.toDouble(),
                        height: bannerAd.size.height.toDouble(),
                        child: AdWidget(ad: bannerAd),
                      ),
                    ],
                  )
                ]
            );
          } else {
            return const SizedBox.shrink();
          }
        },
      ),
    );
  }
}

lib/ad_manager.dart

import 'dart:async';
import 'dart:io' show Platform;
import 'dart:ui';
import 'package:google_mobile_ads/google_mobile_ads.dart';

class AdManager {
  // Test IDs
  // static const String _androidAdUnitId = "ca-app-pub-3940256099942544/6300978111";
  // static const String _iosAdUnitId     = "ca-app-pub-3940256099942544/2934735716";

  // Production IDs
  static const String _androidAdUnitId = "ca-app-pub-0/0";
  static const String _iosAdUnitId     = "ca-app-pub-0/0";

  static String get _adUnitId => Platform.isIOS ? _iosAdUnitId : _androidAdUnitId;

  BannerAd? _bannerAd;
  int _lastWidthPx = 0;
  VoidCallback? _onLoadedCb;
  Timer? _retryTimer;
  int _retryAttempt = 0;

  BannerAd? get bannerAd => _bannerAd;

  Future<void> loadAdaptiveBannerAd(int widthPx, VoidCallback onAdLoaded) async {
    _onLoadedCb = onAdLoaded;
    _lastWidthPx = widthPx;
    _retryAttempt = 0;
    _retryTimer?.cancel();
    _startLoad(widthPx);
  }

  Future<void> _startLoad(int widthPx) async {
    _bannerAd?.dispose();

    AnchoredAdaptiveBannerAdSize? adaptiveSize;
    try {
      adaptiveSize = await AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(widthPx);
    } catch (_) {
      adaptiveSize = null;
    }
    final AdSize size = adaptiveSize ?? AdSize.fullBanner;

    _bannerAd = BannerAd(
      adUnitId: _adUnitId,
      request: const AdRequest(),
      size: size,
      listener: BannerAdListener(
        onAdLoaded: (ad) {
          _retryTimer?.cancel();
          _retryAttempt = 0;
          final cb = _onLoadedCb;
          if (cb != null) {
            cb();
          }
        },
        onAdFailedToLoad: (ad, err) {
          ad.dispose();
          _scheduleRetry();
        },
      ),
    )..load();
  }

  void _scheduleRetry() {
    _retryTimer?.cancel();
    // Exponential backoff: 3s, 6s, 12s, max 30s
    _retryAttempt = (_retryAttempt + 1).clamp(1, 5);
    final seconds = _retryAttempt >= 4 ? 30 : (3 << (_retryAttempt - 1));
    _retryTimer = Timer(Duration(seconds: seconds), () {
      _startLoad(_lastWidthPx > 0 ? _lastWidthPx : 320);
    });
  }

  void dispose() {
    _bannerAd?.dispose();
    _retryTimer?.cancel();
  }

}

lib/app_localizations.dart

import 'package:flutter/widgets.dart';

class AppLocalizations {
  AppLocalizations(this.locale);

  final Locale locale;

  static const List<Locale> supportedLocales = [Locale('en'), Locale('ja')];

  static const LocalizationsDelegate<AppLocalizations> delegate =
      _AppLocalizationsDelegate();

  static AppLocalizations of(BuildContext context) {
    return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
  }

  static const Map<String, Map<String, String>> _localizedValues = {
    'en': {
      'appTitle': 'Basal Metabolism Calculator',
      'heightLabel': 'Height',
      'heightUnit': 'cm',
      'weightLabel': 'Weight',
      'weightUnit': 'kg',
      'ageLabel': 'Age',
      'ageUnit': 'years',
      'genderLabel': 'Gender',
      'male': 'Male',
      'female': 'Female',
      'basalLabel': 'Basal Metabolism',
      'level15Label': 'Calories A needed per day',
      'level175Label': 'Calories B needed per day',
      'level20Label': 'Calories C needed per day',
      'kcalSuffix': 'kcal',
      'settingsTitle': 'Settings',
      'languageLabel': 'Language',
      'themeLabel': 'Theme',
      'showBackgroundLabel': 'Show background image',
      'systemDefault': 'System Default',
      'themeSystem': 'System Default',
      'themeLight': 'Light',
      'themeDark': 'Dark',
      'languageEnglish': 'English',
      'languageJapanese': 'Japanese',
      'setAIntro': 'Indicator using the Harris-Benedict equation (revised version)',
      'setBIntro': 'Indicator using the formula from the National Institute of Health and Nutrition (Japan)',
      'basalDefinition': 'Basal metabolism refers to the energy required to maintain vital life functions even when resting without doing anything. The amount of energy needed per day is generally called basal metabolic rate.',
      'calorieLevelADescription': 'For low activity levels, such as mainly desk work.',
      'calorieLevelBDescription': 'For moderate activity levels, such as mainly desk work but including commuting, shopping, housework, or light sports.',
      'calorieLevelCDescription': 'For high activity levels, such as standing for long periods or regularly engaging in sports or vigorous exercise.',
      'setAFormulaNote': 'This is an estimated result calculated using the Harris-Benedict equation (revised version). Please consider it as a reference only, since there are individual differences.\nMale = 66.4730 + 13.7516w + 5.0033h - 6.7550a\nFemale = 655.0955 + 9.5634w + 1.8496h - 4.6756a\nw: weight(kg) h: height(cm) a: age\nSource: https://en.wikipedia.org/wiki/Harris–Benedict_equation',
      'setBFormulaNote': 'This is an estimated result calculated using the formula from the National Institute of Health and Nutrition. Please consider it as a reference only, since there are individual differences.\n((0.1238 + (0.0481w) + (0.0234h) - (0.0138a) - g) * 1000) / 4.186\ng (male = 0.5473 × 1, female = 0.5473 × 2)\nw: weight(kg) h: height(cm) a: age\nThis formula was newly developed by the National Institute of Health and Nutrition, based on data measured after 2000 from Japanese men and women. It is based on basal metabolism data measured in 71 men and 66 women aged 20–70. The obtained values are only estimates, and the true values are distributed around them. Differences of over 100 kcal/day may occur.\nSource: https://www.nibiohn.go.jp/eiken/'
    },
    'ja': {
      'appTitle': '基礎代謝量計算',
      'heightLabel': '身長',
      'heightUnit': 'cm',
      'weightLabel': '体重',
      'weightUnit': 'kg',
      'ageLabel': '年齢',
      'ageUnit': '歳',
      'genderLabel': '性別',
      'male': '男性',
      'female': '女性',
      'basalLabel': '基礎代謝量',
      'level15Label': '1日に必要なカロリーA',
      'level175Label': '1日に必要なカロリーB',
      'level20Label': '1日に必要なカロリーC',
      'kcalSuffix': 'kcal',
      'settingsTitle': '設定',
      'languageLabel': '言語',
      'themeLabel': 'テーマ',
      'showBackgroundLabel': '背景画像を表示',
      'systemDefault': 'システム設定',
      'themeSystem': 'システム設定',
      'themeLight': 'ライト',
      'themeDark': 'ダーク',
      'languageEnglish': '英語',
      'languageJapanese': '日本語',
      'setAIntro': 'ハリス・ベネディクト方程式(改良版)での指標',
      'setBIntro': '国立健康・栄養研究所の式での指標',
      'basalDefinition': '基礎代謝とは、何もせずじっとしていても生命活動を維持するために必要なエネルギーのことで、一日当たりに必要なエネルギーを一般に基礎代謝量と言います。',
      'calorieLevelADescription': 'デスクワーク中心など、活動レベルが低い場合。',
      'calorieLevelBDescription': 'デスクワーク中心だが、通勤、買い物、家事、軽いスポーツなど、活動レベルが中程度の場合。',
      'calorieLevelCDescription': '立っていることが多く、または日常的にスポーツや活発な運動を行うなど、活動レベルが高い場合。',
      'setAFormulaNote':
          'ハリス・ベネディクト方程式(改良版)を用いた計算結果(推定値)です。個人差が有りますのでひとつの目安としてお考え下さい。\n男性=66.4730+13.7516w+5.0033h-6.7550a\n女性=655.0955+9.5634w+1.8496h-4.6756a\nw(体重kg) h(身長cm) a(年齢)\n出典 https://ja.wikipedia.org/wiki/ハリス-ベネディクトの式',
      'setBFormulaNote':
          '国立健康・栄養研究所の式を用いた計算結果(推定値)です。個人差が有りますのでひとつの目安としてお考え下さい。\n((0.1238+(0.0481w)+(0.0234h)-(0.0138a)-g))*1000/4.186\ng(男性=0.5473*1,女性=0.5473*2)\nw(体重kg) h(身長cm) a(年齢)\n2000年以降に国立健康・栄養研究所で測定された日本人のデータに基づき、 国立健康・栄養研究所が新たに開発したものです。この推定式は、20-70歳代の日本人男女(男性71名、女性66名)を対象に、国立健康・栄養研究所で測定した基礎代謝量のデータから得られたものです。 得られた値はあくまで推定値で、真の値は、この推定値を中心に分布し、100kcal/日以上異なることもありえます。\n出典 https://www.nibiohn.go.jp/eiken/',
    },
  };

  String _translate(String key) {
    final values =
        _localizedValues[locale.languageCode] ?? _localizedValues['en']!;
    return values[key] ?? _localizedValues['en']![key] ?? key;
  }

  String get appTitle => _translate('appTitle');
  String get heightLabel => _translate('heightLabel');
  String get heightUnit => _translate('heightUnit');
  String get weightLabel => _translate('weightLabel');
  String get weightUnit => _translate('weightUnit');
  String get ageLabel => _translate('ageLabel');
  String get ageUnit => _translate('ageUnit');
  String get genderLabel => _translate('genderLabel');
  String get male => _translate('male');
  String get female => _translate('female');
  String get basalLabel => _translate('basalLabel');
  String get level15Label => _translate('level15Label');
  String get level175Label => _translate('level175Label');
  String get level20Label => _translate('level20Label');
  String get kcalSuffix => _translate('kcalSuffix');
  String get settingsTitle => _translate('settingsTitle');
  String get languageLabel => _translate('languageLabel');
  String get themeLabel => _translate('themeLabel');
  String get showBackgroundLabel => _translate('showBackgroundLabel');
  String get systemDefault => _translate('systemDefault');
  String get themeSystem => _translate('themeSystem');
  String get themeLight => _translate('themeLight');
  String get themeDark => _translate('themeDark');
  String get languageEnglish => _translate('languageEnglish');
  String get languageJapanese => _translate('languageJapanese');
  String get setAIntro => _translate('setAIntro');
  String get setBIntro => _translate('setBIntro');
  String get basalDefinition => _translate('basalDefinition');
  String get calorieLevelADescription => _translate('calorieLevelADescription');
  String get calorieLevelBDescription => _translate('calorieLevelBDescription');
  String get calorieLevelCDescription => _translate('calorieLevelCDescription');
  String get setAFormulaNote => _translate('setAFormulaNote');
  String get setBFormulaNote => _translate('setBFormulaNote');
}

class _AppLocalizationsDelegate
    extends LocalizationsDelegate<AppLocalizations> {
  const _AppLocalizationsDelegate();

  @override
  bool isSupported(Locale locale) {
    return AppLocalizations.supportedLocales.any(
      (l) => l.languageCode == locale.languageCode,
    );
  }

  @override
  Future<AppLocalizations> load(Locale locale) async {
    return AppLocalizations(locale);
  }

  @override
  bool shouldReload(covariant LocalizationsDelegate<AppLocalizations> old) =>
      false;
}


lib/app_settings.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

enum ThemeChoice { system, light, dark }

class AppSettings extends ChangeNotifier {
  static const _themeKey = 'valueTheme';
  static const _localeKey = 'valueLocale';
  static const _heightKey = 'valueHeight';
  static const _weightKey = 'valueWeight';
  static const _ageKey = 'valueAge';
  static const _genderKey = 'valueGender';
  static const _showBackgroundImageKey = 'valueShowBackgroundImage';

  late final SharedPreferences _prefs;
  bool _isReady = false;
  ThemeChoice _themeChoice = ThemeChoice.system;
  Locale? _locale;
  bool _showBackgroundImage = true;

  ThemeChoice get themeChoice => _themeChoice;
  Locale? get locale => _locale;
  bool get showBackgroundImage => _showBackgroundImage;
  bool get isReady => _isReady;

  ThemeMode get themeMode {
    switch (_themeChoice) {
      case ThemeChoice.light:
        return ThemeMode.light;
      case ThemeChoice.dark:
        return ThemeMode.dark;
      case ThemeChoice.system:
        return ThemeMode.system;
    }
  }

  SharedPreferences get prefs {
    if (!_isReady) {
      throw StateError(
        'AppSettings.load must complete before accessing prefs.',
      );
    }
    return _prefs;
  }

  Future<void> load() async {
    _prefs = await SharedPreferences.getInstance();
    _themeChoice = _decodeThemeChoice(_prefs.getString(_themeKey));
    final storedLocale = _prefs.getString(_localeKey) ?? '';
    _locale = storedLocale.isEmpty ? null : Locale(storedLocale);
    _showBackgroundImage = _prefs.getBool(_showBackgroundImageKey) ?? true;
    _isReady = true;
  }

  ThemeChoice _decodeThemeChoice(String? value) {
    switch (value) {
      case '0':
      case 'light':
        return ThemeChoice.light;
      case '1':
      case 'dark':
        return ThemeChoice.dark;
      case '2':
      case 'system':
        return ThemeChoice.system;
    }
    return ThemeChoice.system;
  }

  String _encodeThemeChoice(ThemeChoice choice) {
    switch (choice) {
      case ThemeChoice.light:
        return 'light';
      case ThemeChoice.dark:
        return 'dark';
      case ThemeChoice.system:
        return 'system';
    }
  }

  Future<void> updateTheme(ThemeChoice choice) async {
    if (_themeChoice == choice) {
      return;
    }
    _themeChoice = choice;
    notifyListeners();
    await _prefs.setString(_themeKey, _encodeThemeChoice(choice));
  }

  Future<void> updateLocale(String? languageCode) async {
    final normalized = languageCode?.trim() ?? '';
    final newLocale = normalized.isEmpty ? null : Locale(normalized);
    final changed =
        !(_locale == null && newLocale == null) &&
        _locale?.languageCode != newLocale?.languageCode;
    if (changed) {
      _locale = newLocale;
      notifyListeners();
    }
    if (newLocale == null) {
      await _prefs.remove(_localeKey);
    } else {
      await _prefs.setString(_localeKey, newLocale.languageCode);
    }
  }

  Future<void> updateShowBackgroundImage(bool value) async {
    if (_showBackgroundImage == value) {
      return;
    }
    _showBackgroundImage = value;
    notifyListeners();
    await _prefs.setBool(_showBackgroundImageKey, value);
  }

  Future<void> saveUserInputs({
    required String height,
    required String weight,
    required String age,
    required int gender,
  }) async {
    final futures = <Future<bool>>[
      _prefs.setString(_heightKey, height),
      _prefs.setString(_weightKey, weight),
      _prefs.setString(_ageKey, age),
      _prefs.setString(_genderKey, gender.toString()),
    ];
    await Future.wait(futures);
  }
}



lib/main.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';

import 'package:basalmetabolism/app_localizations.dart';
import 'package:basalmetabolism/app_settings.dart';
import 'package:basalmetabolism/settings_page.dart';
import 'package:basalmetabolism/ad_manager.dart';
import 'package:basalmetabolism/ad_banner_widget.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final settings = AppSettings();
  await settings.load();
  runApp(MyApp(settings: settings));
}

class MyApp extends StatefulWidget {
  const MyApp({super.key, required this.settings});

  final AppSettings settings;

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: widget.settings,
      builder: (context, _) {
        return MaterialApp(
          debugShowCheckedModeBanner: false,
          title: 'Basal Metabolism',
          locale: widget.settings.locale,
          supportedLocales: AppLocalizations.supportedLocales,
          localizationsDelegates: const [
            AppLocalizations.delegate,
            GlobalMaterialLocalizations.delegate,
            GlobalWidgetsLocalizations.delegate,
            GlobalCupertinoLocalizations.delegate,
          ],
          themeMode: widget.settings.themeMode,
          theme: ThemeData(
            colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
            useMaterial3: true,
          ),
          darkTheme: ThemeData(
            colorScheme: ColorScheme.fromSeed(
              seedColor: Colors.teal,
              brightness: Brightness.dark,
            ),
            useMaterial3: true,
          ),
          home: HomePage(settings: widget.settings),
        );
      },
    );
  }
}

enum Gender { male, female }

class HomePage extends StatefulWidget {
  const HomePage({super.key, required this.settings});

  final AppSettings settings;

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  late AdManager _adManager;
  final _heightController = TextEditingController();
  final _weightController = TextEditingController();
  final _ageController = TextEditingController();

  Gender _gender = Gender.male;
  bool _restoring = false;
  BmrResultSet _resultSetA = BmrResultSet.zero;
  BmrResultSet _resultSetB = BmrResultSet.zero;

  static const _heightKey = 'valueHeight';
  static const _weightKey = 'valueWeight';
  static const _ageKey = 'valueAge';
  static const _genderKey = 'valueGender';

  @override
  void initState() {
    super.initState();
    _adManager = AdManager();
    _heightController.addListener(_onInputChanged);
    _weightController.addListener(_onInputChanged);
    _ageController.addListener(_onInputChanged);
    _restoreInputs();
  }

  @override
  void dispose() {
    _adManager.dispose();
    _heightController.dispose();
    _weightController.dispose();
    _ageController.dispose();
    super.dispose();
  }

  void _restoreInputs() {
    final prefs = widget.settings.prefs;
    _restoring = true;
    _heightController.text = prefs.getString(_heightKey) ?? '';
    _weightController.text = prefs.getString(_weightKey) ?? '';
    _ageController.text = prefs.getString(_ageKey) ?? '';
    final genderValue = prefs.getString(_genderKey) ?? '1';
    _gender = genderValue == '2' ? Gender.female : Gender.male;
    _restoring = false;
    _updateResults();
  }

  void _onInputChanged() {
    _updateResults();
  }

  void _onGenderChanged(Gender newGender) {
    if (newGender == _gender) {
      return;
    }
    setState(() {
      _gender = newGender;
    });
    _updateResults();
  }

  void _updateResults() {
    if (_restoring) {
      return;
    }
    final height = int.tryParse(_heightController.text) ?? 0;
    final weight = int.tryParse(_weightController.text) ?? 0;
    final age = int.tryParse(_ageController.text) ?? 0;

    final setA = BmrCalculator.calculateSetA(
      height: height,
      weight: weight,
      age: age,
      gender: _gender,
    );
    final setB = BmrCalculator.calculateSetB(
      height: height,
      weight: weight,
      age: age,
      gender: _gender,
    );

    setState(() {
      _resultSetA = setA;
      _resultSetB = setB;
    });

    unawaited(
      widget.settings.saveUserInputs(
        height: _heightController.text,
        weight: _weightController.text,
        age: _ageController.text,
        gender: _gender == Gender.male ? 1 : 2,
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final l = AppLocalizations.of(context);
    final theme = Theme.of(context);
    final colorScheme = theme.colorScheme;
    final levelDescriptions = <String>[
      l.basalDefinition,
      l.calorieLevelADescription,
      l.calorieLevelBDescription,
      l.calorieLevelCDescription,
    ];

    final brightness = theme.brightness;
    final showBackgroundImage = widget.settings.showBackgroundImage;
    final backgroundAsset = brightness == Brightness.dark
        ? 'assets/image/back_dark.png'
        : 'assets/image/back.png';
    final decorationImage = showBackgroundImage
        ? DecorationImage(
            image: AssetImage(backgroundAsset),
            repeat: ImageRepeat.repeat,
          )
        : null;
    final backgroundColor =
        !showBackgroundImage && brightness == Brightness.light
            ? Colors.white
            : Colors.transparent;
    final overlayStyle = (brightness == Brightness.dark
            ? SystemUiOverlayStyle.light
            : SystemUiOverlayStyle.dark)
        .copyWith(statusBarColor: Colors.transparent);
    const accentColor = Color(0x9800E1FF);
    final accentForeground =
        ThemeData.estimateBrightnessForColor(accentColor) == Brightness.dark
            ? Colors.white
            : Colors.black;

    return DecoratedBox(
      decoration: BoxDecoration(
        color: backgroundColor,
        image: decorationImage,
      ),
      child: Scaffold(
        backgroundColor: Colors.transparent,
        appBar: AppBar(
          backgroundColor: Colors.transparent,
          systemOverlayStyle: overlayStyle,
          title: Text(l.appTitle),
          actions: [
            IconButton(
              icon: const Icon(Icons.settings),
              onPressed: () {
                Navigator.of(context).push(
                  MaterialPageRoute(
                    builder: (_) => SettingsPage(settings: widget.settings),
                  ),
                );
              },
            ),
          ],
        ),
        body: GestureDetector(
          behavior: HitTestBehavior.translucent,
          onTap: () => FocusScope.of(context).unfocus(),
          child: SingleChildScrollView(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                _buildNumberField(
                  controller: _heightController,
                  label: l.heightLabel,
                  unit: l.heightUnit,
                ),
                const SizedBox(height: 12),
                _buildNumberField(
                  controller: _weightController,
                  label: l.weightLabel,
                  unit: l.weightUnit,
                ),
                const SizedBox(height: 12),
                _buildNumberField(
                  controller: _ageController,
                  label: l.ageLabel,
                  unit: l.ageUnit,
                ),
                const SizedBox(height: 12),
                Row(
                  children: [
                    SizedBox(
                      width: 120,
                      child: Text(
                        l.genderLabel,
                        textAlign: TextAlign.right,
                        style: theme.textTheme.bodyLarge,
                      ),
                    ),
                    const SizedBox(width: 16),
                    Expanded(
                      child: SegmentedButton<Gender>(
                        style: ButtonStyle(
                          backgroundColor:
                              WidgetStateProperty.resolveWith((states) {
                            if (states.contains(WidgetState.selected)) {
                              return accentColor;
                            }
                            return null;
                          }),
                          foregroundColor:
                              WidgetStateProperty.resolveWith((states) {
                            if (states.contains(WidgetState.selected)) {
                              return accentForeground;
                            }
                            return null;
                          }),
                          overlayColor:
                              WidgetStateProperty.resolveWith((states) {
                            if (states.contains(WidgetState.pressed)) {
                              return accentColor.withValues(alpha: 0.2);
                            }
                            return accentColor.withValues(alpha: 0.12);
                          }),
                          side: WidgetStateProperty.resolveWith((states) {
                            final color = states.contains(WidgetState.selected)
                                ? accentColor
                                : colorScheme.outline;
                            return BorderSide(color: color);
                          }),
                        ),
                        segments: [
                          ButtonSegment(
                              value: Gender.male, label: Text(l.male)),
                          ButtonSegment(
                            value: Gender.female,
                            label: Text(l.female),
                          ),
                        ],
                        selected: <Gender>{_gender},
                        onSelectionChanged: (selection) {
                          if (selection.isEmpty) {
                            return;
                          }
                          _onGenderChanged(selection.first);
                        },
                      ),
                    ),
                  ],
                ),
                const SizedBox(height: 24),
                _buildResultCard(
                  intro: l.setAIntro,
                  result: _resultSetA,
                  localization: l,
                  descriptions: levelDescriptions,
                  formulaNote: l.setAFormulaNote,
                ),
                const SizedBox(height: 16),
                _buildResultCard(
                  intro: l.setBIntro,
                  result: _resultSetB,
                  localization: l,
                  descriptions: levelDescriptions,
                  formulaNote: l.setBFormulaNote,
                ),
                const SizedBox(height: 100),
              ],
            ),
          ),
        ),
        bottomNavigationBar: AdBannerWidget(adManager: _adManager),
      ),
    );
  }

  Widget _buildNumberField({
    required TextEditingController controller,
    required String label,
    required String unit,
  }) {
    final textTheme = Theme.of(context).textTheme;
    return Row(
      crossAxisAlignment: CrossAxisAlignment.center,
      children: [
        SizedBox(
          width: 120,
          child: Text(
            label,
            textAlign: TextAlign.right,
            style: textTheme.bodyLarge,
          ),
        ),
        const SizedBox(width: 16),
        Expanded(
          child: TextField(
            controller: controller,
            keyboardType: TextInputType.number,
            inputFormatters: [FilteringTextInputFormatter.digitsOnly],
            textAlign: TextAlign.left,
            decoration: InputDecoration(
              suffixText: unit,
              border: const OutlineInputBorder(),
            ),
          ),
        ),
      ],
    );
  }

  Widget _buildResultCard({
    required String intro,
    required BmrResultSet result,
    required AppLocalizations localization,
    required List<String> descriptions,
    required String formulaNote,
  }) {
    String descriptionAt(int index) =>
        index < descriptions.length ? descriptions[index] : '';

    final entries = <_ResultEntry>[
      _ResultEntry(
        label: localization.basalLabel,
        value: result.basal,
        description: descriptionAt(0),
      ),
      _ResultEntry(
        label: localization.level15Label,
        value: result.level15,
        description: descriptionAt(1),
      ),
      _ResultEntry(
        label: localization.level175Label,
        value: result.level175,
        description: descriptionAt(2),
      ),
      _ResultEntry(
        label: localization.level20Label,
        value: result.level20,
        description: descriptionAt(3),
      ),
    ];

    final theme = Theme.of(context);
    final textTheme = theme.textTheme;
    final cardColor = theme.brightness == Brightness.dark
        ? Colors.black.withValues(alpha: 0.5)
        : Colors.white.withValues(alpha: 0.6);

    return Card(
      color: cardColor,
      elevation: 0,
      shadowColor: Colors.transparent,
      surfaceTintColor: Colors.transparent,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(intro, style: textTheme.titleMedium),
            const SizedBox(height: 16),
            for (var i = 0; i < entries.length; i++) ...[
              _buildResultRow(
                entries[i].label,
                entries[i].value,
                localization,
                description: entries[i].description,
              ),
              if (i != entries.length - 1) const SizedBox(height: 12),
            ],
            const SizedBox(height: 12),
            Text(
              formulaNote,
              style: textTheme.bodySmall ?? textTheme.bodyMedium,
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildResultRow(
    String label,
    int value,
    AppLocalizations localization, {
    String? description,
  }) {
    final textTheme = Theme.of(context).textTheme;
    final descriptionStyle = textTheme.bodySmall ?? textTheme.bodyMedium;

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Expanded(child: Text(label, style: textTheme.titleMedium)),
            Text('${value.toString()} ${localization.kcalSuffix}',
                style: textTheme.titleMedium),
          ],
        ),
        if (description != null && description.isNotEmpty)
          Padding(
            padding: const EdgeInsets.only(top: 4),
            child: Text(description, style: descriptionStyle),
          ),
      ],
    );
  }
}

class _ResultEntry {
  const _ResultEntry({
    required this.label,
    required this.value,
    required this.description,
  });

  final String label;
  final int value;
  final String description;
}

class BmrResultSet {
  const BmrResultSet({
    required this.basal,
    required this.level15,
    required this.level175,
    required this.level20,
  });

  final int basal;
  final int level15;
  final int level175;
  final int level20;

  static const BmrResultSet zero = BmrResultSet(
    basal: 0,
    level15: 0,
    level175: 0,
    level20: 0,
  );
}

class BmrCalculator {
  static BmrResultSet calculateSetA({
    required int height,
    required int weight,
    required int age,
    required Gender gender,
  }) {
    final double rawBase = gender == Gender.male
        ? 66.4730 + (13.7516 * weight) + (5.0033 * height) - (6.7550 * age)
        : 655.0955 + (9.5634 * weight) + (1.8496 * height) - (4.6756 * age);
    final int base = rawBase.toInt();
    final int level15 = (base * 1.5).toInt();
    final int level175 = (base * 1.75).toInt();
    final int level20 = (base * 2).toInt();
    return BmrResultSet(
      basal: base,
      level15: level15,
      level175: level175,
      level20: level20,
    );
  }

  static BmrResultSet calculateSetB({
    required int height,
    required int weight,
    required int age,
    required Gender gender,
  }) {
    final double baseTerm =
        0.1238 + (0.0481 * weight) + (0.0234 * height) - (0.0138 * age);
    final double rawBase = gender == Gender.male
        ? ((baseTerm - 0.5473) * 1000) / 4.186
        : ((baseTerm - (0.5473 * 2)) * 1000) / 4.186;
    final int base = rawBase.toInt();
    final int level15 = (base * 1.5).toInt();
    final int level175 = (base * 1.75).toInt();
    final int level20 = (base * 2).toInt();
    return BmrResultSet(
      basal: base,
      level15: level15,
      level175: level175,
      level20: level20,
    );
  }
}

lib/settings_page.dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'package:basalmetabolism/app_localizations.dart';
import 'package:basalmetabolism/app_settings.dart';
import 'package:basalmetabolism/ad_manager.dart';
import 'package:basalmetabolism/ad_banner_widget.dart';

class SettingsPage extends StatefulWidget {
  const SettingsPage({super.key, required this.settings});

  final AppSettings settings;

  @override
  State<SettingsPage> createState() => _SettingsPageState();
}

class _SettingsPageState extends State<SettingsPage> {
  late ThemeChoice _themeChoice;
  String? _languageCode;
  late bool _showBackgroundImage;
  late AdManager _adManager;

  @override
  void initState() {
    super.initState();
    _adManager = AdManager();
    _themeChoice = widget.settings.themeChoice;
    _languageCode = widget.settings.locale?.languageCode;
    _showBackgroundImage = widget.settings.showBackgroundImage;
  }

  @override
  void dispose() {
    _adManager.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final l = AppLocalizations.of(context);
    final brightness = Theme.of(context).brightness;
    final overlayStyle = (brightness == Brightness.dark
            ? SystemUiOverlayStyle.light
            : SystemUiOverlayStyle.dark)
        .copyWith(statusBarColor: Colors.transparent);
    final backgroundAsset = brightness == Brightness.dark
        ? 'assets/image/back_dark.png'
        : 'assets/image/back.png';
    final decorationImage = _showBackgroundImage
        ? DecorationImage(
            image: AssetImage(backgroundAsset),
            repeat: ImageRepeat.repeat,
          )
        : null;
    final backgroundColor =
        !_showBackgroundImage && brightness == Brightness.light
            ? Colors.white
            : Colors.transparent;

    return DecoratedBox(
      decoration: BoxDecoration(
        color: backgroundColor,
        image: decorationImage,
      ),
      child: Scaffold(
        backgroundColor: Colors.transparent,
        appBar: AppBar(
          backgroundColor: Colors.transparent,
          systemOverlayStyle: overlayStyle,
        ),
        body: SafeArea(
          child: ListView(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            children: [
              ListTile(
                title: Text(l.languageLabel),
                trailing: DropdownButton<String?>(
                  value: _languageCode,
                  hint: Text(l.systemDefault),
                  items: [
                    DropdownMenuItem<String?>(
                      value: null,
                      child: Text(l.systemDefault),
                    ),
                    DropdownMenuItem<String?>(
                      value: 'en',
                      child: Text(l.languageEnglish),
                    ),
                    DropdownMenuItem<String?>(
                      value: 'ja',
                      child: Text(l.languageJapanese),
                    ),
                  ],
                  onChanged: (value) async {
                    setState(() => _languageCode = value);
                    await widget.settings.updateLocale(value);
                  },
                ),
              ),
              ListTile(
                title: Text(l.themeLabel),
                trailing: DropdownButton<ThemeChoice>(
                  value: _themeChoice,
                  items: [
                    DropdownMenuItem(
                      value: ThemeChoice.system,
                      child: Text(l.themeSystem),
                    ),
                    DropdownMenuItem(
                      value: ThemeChoice.light,
                      child: Text(l.themeLight),
                    ),
                    DropdownMenuItem(
                      value: ThemeChoice.dark,
                      child: Text(l.themeDark),
                    ),
                  ],
                  onChanged: (choice) async {
                    if (choice == null) {
                      return;
                    }
                    setState(() => _themeChoice = choice);
                    await widget.settings.updateTheme(choice);
                  },
                ),
              ),
              SwitchListTile.adaptive(
                contentPadding: EdgeInsets.zero,
                title: Text(l.showBackgroundLabel),
                value: _showBackgroundImage,
                onChanged: (value) async {
                  setState(() => _showBackgroundImage = value);
                  await widget.settings.updateShowBackgroundImage(value);
                },
              ),
            ],
          ),
        ),
        bottomNavigationBar: AdBannerWidget(adManager: _adManager),
      ),
    );
  }
}