ソースコード source code

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

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

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

pubspec.yaml

name: barcodereader
description: "barcodereader"
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev

# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 2.1.0+33

environment:
  sdk: ">=3.3.0 <4.0.0"

# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  mobile_scanner: ^7.0.1
  shared_preferences: ^2.0.15
  flutter_toastr: ^1.0.3
  url_launcher: ^6.1.5
  share_plus: ^12.0.0
  google_mobile_ads: ^6.0.0
  provider: ^6.0.3
  clipboard: ^2.0.2
  intl: ^0.20.2
  audioplayers: ^6.0.0

dev_dependencies:
  flutter_test:
    sdk: flutter

  flutter_launcher_icons: ^0.14.4    #flutter pub run flutter_launcher_icons
  flutter_native_splash: ^2.3.5     #flutter pub run flutter_native_splash:create

  # The "flutter_lints" package below contains a set of recommended lints to
  # encourage good coding practices. The lint set provided by the package is
  # activated in the `analysis_options.yaml` file located at the root of your
  # package. See that file for information about deactivating specific lint
  # rules and activating additional ones.
  flutter_lints: ^6.0.0

# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

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'

# The following section is specific to Flutter packages.
flutter:
  generate: true # Add this line

  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true

  assets:
    - assets/image/
    - assets/sound/

  # To add assets to your application, add an assets section, like this:
  # assets:
  #   - images/a_dot_burr.jpeg
  #   - images/a_dot_ham.jpeg

  # An image asset can refer to one or more resolution-specific "variants", see
  # https://flutter.dev/to/resolution-aware-images

  # For details regarding adding assets from package dependencies, see
  # https://flutter.dev/to/asset-from-package

  # To add custom fonts to your application, add a fonts section here,
  # in this "flutter" section. Each entry in this list should have a
  # "family" key with the font family name, and a "fonts" key with a
  # list giving the asset and other descriptors for the font. For
  # example:
  # fonts:
  #   - family: Schyler
  #     fonts:
  #       - asset: fonts/Schyler-Regular.ttf
  #       - asset: fonts/Schyler-Italic.ttf
  #         style: italic
  #   - family: Trajan Pro
  #     fonts:
  #       - asset: fonts/TrajanPro.ttf
  #       - asset: fonts/TrajanPro_Bold.ttf
  #         weight: 700
  #
  # For details regarding fonts from package dependencies,
  # see https://flutter.dev/to/font-from-package

lib/ad_banner_widget.dart

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

import 'package:barcodereader/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 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-000/000";
  static const String _iosAdUnitId     = "ca-app-pub-000/000";

  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/home_page.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import 'l10n/app_localizations.dart';

import 'package:barcodereader/ad_manager.dart';
import 'package:barcodereader/ad_banner_widget.dart';
import 'package:barcodereader/scan_list_page.dart';
import 'package:barcodereader/scan_provider.dart';
import 'package:barcodereader/scanner_page.dart';
import 'package:barcodereader/settings_page.dart';

class HomePage extends StatefulWidget {
  const HomePage({super.key});

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

class _HomePageState extends State<HomePage> {
  late AdManager _adManager;
  final _textController = TextEditingController();
  final ScrollController _scrollController = ScrollController();
  late ScanProvider _scanProvider;

  @override
  void initState() {
    super.initState();
    _adManager = AdManager();
    _scanProvider = context.read<ScanProvider>();
    _scanProvider.loadScanResults().then((_) {
      _updateTextField();
    });
    _scanProvider.addListener(_updateTextField);
  }

  @override
  void dispose() {
    _scanProvider.removeListener(_updateTextField);
    _textController.dispose();
    _scrollController.dispose();
    _adManager.dispose();
    super.dispose();
  }

  Future<void> _scanBarcode() async {
    await Navigator.of(context).push(
      MaterialPageRoute(
        builder: (context) => ScannerPage(
          onScan: (result) async {
            await _scanProvider.addScanResult(result);
          },
        ),
      ),
    );
  }

  void _updateTextField() {
    _textController.text = _scanProvider.getFormattedResults();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeOut,
        );
      }
    });
  }

  void _copyToClipboard(String text) {
    if (text.isEmpty) return;
    Clipboard.setData(ClipboardData(text: text));
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(AppLocalizations.of(context)!.copiedToClipboard),
        duration: const Duration(seconds: 1),
      ),
    );
  }

  void _shareText(String text) {
    if (text.isEmpty) {
      return;
    }
    SharePlus.instance.share(ShareParams(text: text));
  }

  void _launchUrlOrShare(String text) async {
    if (text.isEmpty) return;
    Uri uri;
    final parsedUri = Uri.tryParse(text);
    if (parsedUri != null &&
        (parsedUri.scheme == 'http' || parsedUri.scheme == 'https')) {
      uri = parsedUri;
    } else {
      uri = Uri.parse(
        'https://www.google.com/search?q=${Uri.encodeComponent(text)}',
      );
    }
    try {
      if (!await launchUrl(uri)) {
        if (!mounted) return;
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text(
              AppLocalizations.of(context)!.couldNotLaunchBrowserFor(
                uri.toString(),
              ),
            ),
          ),
        );
      }
    } catch (e) {
      if (!mounted) return;
      ScaffoldMessenger.of(
        context,
      ).showSnackBar(
        SnackBar(
          content: Text(
            AppLocalizations.of(context)!.errorLaunchingBrowser(
              e.toString(),
            ),
          ),
        ),
      );
    }
  }

  Future<bool> _showConfirmDialog(String title) async {
    return await showDialog<bool>(
          context: context,
          builder: (context) => AlertDialog(
            title: Text(title),
            actions: [
              TextButton(
                onPressed: () => Navigator.of(context).pop(false),
                child: Text(AppLocalizations.of(context)!.cancel),
              ),
              TextButton(
                onPressed: () => Navigator.of(context).pop(true),
                child: Text(AppLocalizations.of(context)!.ok),
              ),
            ],
          ),
        ) ??
        false;
  }

  Future<void> _showMessageDialog({String? title, required List<String> messages}) async {
    await showDialog<void>(
      context: context,
      builder: (context) => AlertDialog(
        title: title != null ? Text(title) : null,
        content: messages.length == 1
            ? Text(messages.first)
            : Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  for (var i = 0; i < messages.length; i++)
                    Padding(
                      padding: EdgeInsets.only(bottom: i == messages.length - 1 ? 0 : 8.0),
                      child: Text(messages[i]),
                    ),
                ],
              ),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(context).pop(),
            child: Text(AppLocalizations.of(context)!.ok),
          ),
        ],
      ),
    );
  }

  Future<void> _handleDeleteDuplicates() async {
    if (!await _showConfirmDialog(
      AppLocalizations.of(context)!.deleteDuplicatesConfirmation,
    )) {
      return;
    }

    final results = await _scanProvider.removeDuplicateScanResults();
    if (!mounted) return;

    if (results.isEmpty) {
      await _showMessageDialog(
        title: AppLocalizations.of(context)!.deleteDuplicates,
        messages: [AppLocalizations.of(context)!.noDuplicateDataFound],
      );
      return;
    }

    await _showMessageDialog(
      title: AppLocalizations.of(context)!.duplicateRemovalResultTitle,
      messages: results
          .map(
            (entry) => AppLocalizations.of(context)!.duplicateRemovalEntry(
              entry.removedCount,
              entry.data,
            ),
          )
          .toList(),
    );
  }

  Widget _buildFullWidthButton(
    String text,
    VoidCallback onPressed, {
    Color? backgroundColor,
    double height = 38.0,
  }) {
    final isDark = Theme.of(context).brightness == Brightness.dark;
    final defaultBgColor = isDark ? Colors.grey[800] : Colors.blueGrey[400];
    return Container(
      height: height,
      decoration: BoxDecoration(
        color: backgroundColor ?? defaultBgColor,
        border: Border(
          bottom: BorderSide(
            color: isDark ? Colors.black : Colors.white,
            width: 1.0,
          ),
        ),
      ),
      child: SizedBox.expand(
        child: TextButton(onPressed: onPressed, child: Text(text)),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final isDark = Theme.of(context).brightness == Brightness.dark;
    const otherButtonHeight = 40.0;

    return Scaffold(
      appBar: AppBar(
        title: const Text(''),
        elevation: 0,
        actions: [
          IconButton(
            icon: const Icon(Icons.list),
            onPressed: () => Navigator.of(context).push(
              MaterialPageRoute(builder: (context) => const ScanListPage()),
            ),
          ),
          IconButton(
            icon: const Icon(Icons.settings),
            onPressed: () => Navigator.of(context).push(
              MaterialPageRoute(builder: (context) => const SettingsPage()),
            ),
          ),
        ],
      ),
      body: SafeArea(
        child: Column(
          children: [
            Divider(height: 1, color: isDark ? Colors.black : Colors.white),
            _buildFullWidthButton(
              AppLocalizations.of(context)!.scan,
              _scanBarcode,
              backgroundColor:
                  isDark ? const Color(0xFF3E309E) : const Color(0xFF9DA0FC),
              height: 76.0,
            ),
            _buildFullWidthButton(
              AppLocalizations.of(context)!.copy,
              () async {
                if (await _showConfirmDialog(
                  AppLocalizations.of(context)!.copyAll,
                )) {
                  _copyToClipboard(_scanProvider.toCsv());
                }
              },
              backgroundColor:
                  isDark ? const Color(0xFF372A8B) : const Color(0xFF909FFF),
              height: otherButtonHeight,
            ),
            _buildFullWidthButton(
              AppLocalizations.of(context)!.send,
              () async {
                if (await _showConfirmDialog(
                  AppLocalizations.of(context)!.sendAll,
                )) {
                  _shareText(_scanProvider.toCsv());
                }
              },
              backgroundColor:
                  isDark ? const Color(0xFF372A8B) : const Color(0xFF909FFF),
              height: otherButtonHeight,
            ),
            _buildFullWidthButton(
              AppLocalizations.of(context)!.deleteDuplicates,
              () {
                unawaited(_handleDeleteDuplicates());
              },
              backgroundColor:
                  isDark ? const Color(0xFF372A8B) : const Color(0xFF909FFF),
              height: otherButtonHeight,
            ),
            _buildFullWidthButton(
              AppLocalizations.of(context)!.deleteAll,
              () async {
                if (await _showConfirmDialog(
                  AppLocalizations.of(context)!.deleteAll,
                )) {
                  _scanProvider.deleteAllScanResults();
                }
              },
              backgroundColor:
                  isDark ? const Color(0xFF372A8B) : const Color(0xFF909FFF),
              height: otherButtonHeight,
            ),
            _buildFullWidthButton(
              AppLocalizations.of(context)!.copyLast,
              () => _copyToClipboard(_scanProvider.getLastScanContent()),
              backgroundColor:
                  isDark ? const Color(0xFF32277C) : const Color(0xFF9FADFF),
              height: otherButtonHeight,
            ),
            _buildFullWidthButton(
              AppLocalizations.of(context)!.sendLast,
              () => _shareText(_scanProvider.getLastScanContent()),
              backgroundColor:
                  isDark ? const Color(0xFF32277C) : const Color(0xFF9FADFF),
              height: otherButtonHeight,
            ),
            _buildFullWidthButton(
              AppLocalizations.of(context)!.browserLast,
              () => _launchUrlOrShare(_scanProvider.getLastScanContent()),
              backgroundColor:
                  isDark ? const Color(0xFF32277C) : const Color(0xFF9FADFF),
              height: otherButtonHeight,
            ),
            _buildFullWidthButton(
              AppLocalizations.of(context)!.deleteLast,
              () async {
                if (await _showConfirmDialog(
                  AppLocalizations.of(context)!.deleteLastConfirmation,
                )) {
                  _scanProvider.deleteLastScanResult();
                }
              },
              backgroundColor:
                  isDark ? const Color(0xFF32277C) : const Color(0xFF9FADFF),
              height: otherButtonHeight,
            ),
            Expanded(
              child: Padding(
                padding: const EdgeInsets.fromLTRB(8.0, 8.0, 8.0, 8.0),
                child: TextField(
                  controller: _textController,
                  scrollController: _scrollController,
                  readOnly: true,
                  maxLines: null,
                  expands: true,
                  textAlignVertical: TextAlignVertical.top,
                  decoration: const InputDecoration(border: InputBorder.none),
                ),
              ),
            ),
          ],
        ),
      ),
      bottomNavigationBar: AdBannerWidget(adManager: _adManager),
    );
  }

}

lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:provider/provider.dart';
import 'l10n/app_localizations.dart';

import 'package:barcodereader/home_page.dart';
import 'package:barcodereader/scan_provider.dart';
import 'package:barcodereader/settings_provider.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  MobileAds.instance.initialize();
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => ScanProvider()),
        ChangeNotifierProvider(create: (context) => SettingsProvider()),
      ],
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return Consumer<SettingsProvider>(
      builder: (context, settings, child) {
        return MaterialApp(
          onGenerateTitle: (context) {
            return AppLocalizations.of(context)!.appName;
          },
          debugShowCheckedModeBanner: false,
          theme: ThemeData(
            brightness: Brightness.light,
            scaffoldBackgroundColor: const Color(0xFFEEEEEE),
            appBarTheme: const AppBarTheme(
              backgroundColor: Color(0xFF9FADFF),
              foregroundColor: Colors.black,
              systemOverlayStyle: SystemUiOverlayStyle.dark,
            ),
            inputDecorationTheme: const InputDecorationTheme(
              filled: true,
              fillColor: Colors.white,
              border: OutlineInputBorder(),
            ),
            textButtonTheme: TextButtonThemeData(
              style: TextButton.styleFrom(
                foregroundColor: Colors.black87,
                shape: const RoundedRectangleBorder(
                  borderRadius: BorderRadius.zero,
                ),
              ),
            ),
          ),
          darkTheme: ThemeData(
            brightness: Brightness.dark,
            scaffoldBackgroundColor: const Color(0xFF222222),
            appBarTheme: const AppBarTheme(
              backgroundColor: Color(0xFF32277C),
              foregroundColor: Colors.white,
              systemOverlayStyle: SystemUiOverlayStyle.light,
            ),
            inputDecorationTheme: const InputDecorationTheme(
              filled: true,
              fillColor: Colors.black,
              border: OutlineInputBorder(),
            ),
            textButtonTheme: TextButtonThemeData(
              style: TextButton.styleFrom(
                foregroundColor: Colors.white,
                shape: const RoundedRectangleBorder(
                  borderRadius: BorderRadius.zero,
                ),
              ),
            ),
          ),
          themeMode: settings.themeMode,
          locale: settings.appLocale,
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: const HomePage(),
        );
      },
    );
  }
}

lib/scan_list_page.dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import 'l10n/app_localizations.dart';

import 'package:barcodereader/scan_provider.dart';
import 'package:barcodereader/ad_manager.dart';

class ScanListPage extends StatefulWidget {
  const ScanListPage({super.key});
  @override
  State<ScanListPage> createState() => _ScanListPageState();
}

class _ScanListPageState extends State<ScanListPage> {
  final ScrollController _scrollController = ScrollController();
  late AdManager _adManager;
  bool _isAdLoaded = false;
  int? _lastBannerWidthDp;

  @override
  void initState() {
    super.initState();
    _adManager = AdManager();
    // buildが完了した直後に実行
    WidgetsBinding.instance.addPostFrameCallback((_) {
      // 遅延させてからスクロールを実行
      Timer(const Duration(milliseconds: 50), _scrollToBottom);
    });
  }

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

  void _scrollToBottom() {
    // scrollControllerがListViewに紐付いていて、スクロール可能な場合
    if (_scrollController.hasClients) {
      _scrollController.animateTo(
        _scrollController.position.maxScrollExtent,
        duration: const Duration(milliseconds: 300),
        curve: Curves.easeOut,
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    final l = AppLocalizations.of(context)!;
    return Consumer<ScanProvider>(
      builder: (context, scanProvider, child) {
        // スキャン結果が空の場合の表示
        if (scanProvider.scanResults.isEmpty) {
          return Scaffold(
            appBar: AppBar(
              backgroundColor: Colors.transparent,
              title: Text(l.scanHistory),
              centerTitle: true,
            ),
            body: Center(
              child: Text(l.noScanHistory),
            ),
          );
        }
        // スキャン結果をリスト表示
        return Scaffold(
          appBar: AppBar(
            backgroundColor: Colors.transparent,
            title: Text(l.scanHistory),
            centerTitle: true,
          ),
          body: SafeArea(
            child: ListView.builder(
              controller: _scrollController, // コントローラーを紐付け
              padding: const EdgeInsets.all(8.0),
              itemCount: scanProvider.scanResults.length,
              itemBuilder: (context, index) {
                // 古いものが上、新しいものが下になるようにそのままのindexを使用
                final result = scanProvider.scanResults[index];
                final parts = result.split(',');
                // データのパース
                final timestamp =
                    parts.isNotEmpty
                        ? parts[0]
                        : l.noDate;
                final format = parts.length > 1
                    ? parts[1].split('.').last
                    : l.unknownFormat;
                final content = parts.length > 2
                    ? parts.sublist(2).join(',')
                    : l.noContent;

                // デフォルトのテキストスタイルを取得
                final textTheme = Theme.of(context).textTheme;
                final titleStyle = textTheme.titleMedium;

                return Card(
                  margin: const EdgeInsets.symmetric(vertical: 4.0),
                  child: Dismissible(
                    key: Key(result + index.toString()), // ユニークなキー
                    direction: DismissDirection.endToStart,
                    background: Container(
                      color: Colors.red,
                      alignment: Alignment.centerRight,
                      padding: const EdgeInsets.symmetric(horizontal: 20.0),
                      child: const Icon(Icons.delete, color: Colors.white),
                    ),
                    onDismissed: (direction) {
                      // Provider経由でデータを削除
                      scanProvider.deleteScanResult(index);
                      ScaffoldMessenger.of(context).showSnackBar(
                        SnackBar(
                          content: Text(
                            l.resultDeleted,
                          ),
                          duration: const Duration(seconds: 1),
                        ),
                      );
                    },
                    child: ListTile(
                      title: Text('$timestamp - $format'),
                      subtitle: Text(content, style: titleStyle),
                      onTap: () {
                        // Send / Delete のオプションダイアログを表示
                        showDialog(
                          context: context,
                          builder: (context) => AlertDialog(
                            title: Text(content),
                            actions: <Widget>[
                              TextButton(
                                child: Text(
                                  l.sendAction,
                                ),
                                onPressed: () {
                                  Navigator.of(context).pop();
                                  SharePlus.instance.share(ShareParams(text: content));
                                },
                              ),
                              TextButton(
                                child: Text(
                                  l.deleteAction,
                                ),
                                onPressed: () {
                                  Navigator.of(context).pop();
                                  scanProvider.deleteScanResult(index);
                                  ScaffoldMessenger.of(
                                    context,
                                  ).showSnackBar(
                                    SnackBar(
                                      content: Text(
                                        l
                                            .resultDeleted,
                                      ),
                                      duration: const Duration(seconds: 1),
                                    ),
                                  );
                                },
                              ),
                              TextButton(
                                child: Text(
                                  l.cancel,
                                ),
                                onPressed: () {
                                  Navigator.of(context).pop();
                                },
                              ),
                            ],
                          ),
                        );
                      },
                    ),
                  ),
                );
              },
            ),
          ),
          bottomNavigationBar: _buildAdBanner()
        );
      },
    );
  }

  Widget _buildAdBanner() {
    return SafeArea(
        child: LayoutBuilder(
          builder: (context, constraints) {
            final int width = constraints.maxWidth.isFinite ? constraints.maxWidth.truncate() : MediaQuery.of(context).size.width.truncate();
            if (width > 0) {
              WidgetsBinding.instance.addPostFrameCallback((_) {
                if (mounted) {
                  _updateAdBannerForWidth(width);
                }
              });
            }
            return _isAdLoaded && _adManager.bannerAd != null
              ? Row(mainAxisAlignment: MainAxisAlignment.center, children: [
                SizedBox(
                  width: _adManager.bannerAd!.size.width.toDouble(),
                  height: _adManager.bannerAd!.size.height.toDouble(),
                  child: AdWidget(ad: _adManager.bannerAd!),
                )
              ])
              : const SizedBox.shrink();
          },
        )
    );
  }

  void _updateAdBannerForWidth(int widthDp) {
    if (widthDp <= 0) {
      return;
    }
    if (_lastBannerWidthDp == widthDp && _isAdLoaded && _adManager.bannerAd != null && _adManager.bannerAd!.size.width == widthDp) {
      return;
    }
    _lastBannerWidthDp = widthDp;
    _adManager.loadAdaptiveBannerAd(widthDp, () {
      if (mounted) {
        setState(() => _isAdLoaded = true);
      }
    });
  }

}

lib/scan_provider.dart

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

class DuplicateRemovalEntry {
  const DuplicateRemovalEntry({required this.data, required this.removedCount});

  final String data;
  final int removedCount;
}

class ScanProvider extends ChangeNotifier {
  List<String> _scanResults = [];
  static const String _scanResultsKey = 'scan_results';
  List<String> get scanResults => _scanResults;

  Future<void> loadScanResults() async {
    final prefs = await SharedPreferences.getInstance();
    _scanResults = prefs.getStringList(_scanResultsKey) ?? [];
    notifyListeners();
  }

  Future<void> addScanResult(String result) async {
    _scanResults.add(result);
    await _saveResults();
  }

  Future<void> deleteScanResult(int index) async {
    if (index >= 0 && index < _scanResults.length) {
      _scanResults.removeAt(index);
      await _saveResults();
    }
  }

  Future<void> deleteLastScanResult() async {
    if (_scanResults.isNotEmpty) {
      _scanResults.removeLast();
      await _saveResults();
    }
  }

  Future<void> deleteAllScanResults() async {
    _scanResults.clear();
    await _saveResults();
  }

  Future<List<DuplicateRemovalEntry>> removeDuplicateScanResults() async {
    final seenKeys = <String>{};
    final removalCounts = <String, int>{};
    final displayTexts = <String, String>{};
    final indexesToRemove = <int>[];

    for (var i = 0; i < _scanResults.length; i++) {
      final entry = _scanResults[i];
      final parts = entry.split(',');

      final hasContent = parts.length >= 3;
      final format = hasContent ? parts[1] : '';
      final content = hasContent ? parts.sublist(2).join(',') : entry;
      final key = hasContent ? '$format::$content' : entry;

      displayTexts.putIfAbsent(key, () => content);

      if (!seenKeys.add(key)) {
        removalCounts.update(key, (value) => value + 1, ifAbsent: () => 1);
        indexesToRemove.add(i);
      }
    }

    if (indexesToRemove.isEmpty) {
      return const <DuplicateRemovalEntry>[];
    }

    indexesToRemove.sort((a, b) => b.compareTo(a));
    for (final index in indexesToRemove) {
      _scanResults.removeAt(index);
    }

    await _saveResults();

    return removalCounts.entries
        .map(
          (entry) => DuplicateRemovalEntry(
            data: displayTexts[entry.key] ?? entry.key,
            removedCount: entry.value,
          ),
        )
        .toList();
  }

  Future<void> _saveResults() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setStringList(_scanResultsKey, _scanResults);
    notifyListeners();
  }

  String getFormattedResults() {
    return _scanResults.map((res) {
      final parts = res.split(',');
      if (parts.length >= 2) {
        parts[1] = parts[1].split('.').last;
        return parts.join(',');
      }
      return res;
    }).join('\n');
  }

  String getLastScanContent() {
    if (_scanResults.isEmpty) return '';
    final parts = _scanResults.last.split(',');
    if (parts.length >= 3) {
      return parts.sublist(2).join(',');
    }
    return _scanResults.last;
  }

  String toCsv() {
    return _scanResults
        .map((line) {
          final parts = line.split(',');
          if (parts.length >= 3) {
            final timestamp = parts[0];
            final format = parts[1];
            final content =
                '"${parts.sublist(2).join(',').replaceAll('"', '""')}"';
            return '"$timestamp","$format",$content';
          }
          return line;
        })
        .join('\n');
  }
}

lib/scanner_page.dart

import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:barcodereader/settings_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import 'l10n/app_localizations.dart';

class ScannerPage extends StatefulWidget {
  final Function(String result) onScan;
  const ScannerPage({super.key, required this.onScan});
  @override
  State<ScannerPage> createState() => _ScannerPageState();
}

class _ScannerPageState extends State<ScannerPage> {
  final MobileScannerController _scannerController = MobileScannerController();
  bool _isScanPaused = false;
  bool _isFlashing = false;

  @override
  void initState() {
    super.initState();
    _setOrientation();
  }

  void _setOrientation() {
    final settings = context.read<SettingsProvider>();
    if (settings.lockOrientation) {
      SystemChrome.setPreferredOrientations([
        DeviceOrientation.portraitUp,
        DeviceOrientation.portraitDown,
      ]);
    }
  }

  @override
  void dispose() {
    _scannerController.dispose();
    SystemChrome.setPreferredOrientations([
      DeviceOrientation.portraitUp,
      DeviceOrientation.portraitDown,
      DeviceOrientation.landscapeLeft,
      DeviceOrientation.landscapeRight,
    ]);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final settings = context.watch<SettingsProvider>();
    return Scaffold(
      appBar: AppBar(
        backgroundColor: _isFlashing ? Colors.yellow : null,
        foregroundColor: _isFlashing ? Colors.black : null,
        actions: [
          IconButton(
            icon: ValueListenableBuilder<MobileScannerState>(
              valueListenable: _scannerController,
              builder: (context, state, child) {
                switch (state.torchState) {
                  case TorchState.off:
                    return Icon(
                      Icons.flash_off,
                      color: _isFlashing ? Colors.grey : Colors.white,
                    );
                  case TorchState.on:
                    return const Icon(Icons.flash_on, color: Colors.yellow);
                  default:
                    return const Icon(Icons.no_flash, color: Colors.grey);
                }
              },
            ),
            onPressed: () => _scannerController.toggleTorch(),
          ),
          IconButton(
            icon: const Icon(Icons.flip_camera_ios),
            onPressed: () => _scannerController.switchCamera(),
          ),
        ],
      ),
      body: MobileScanner(
        controller: _scannerController,
        onDetect: (capture) {
          if (_isScanPaused) return;

          final List<Barcode> barcodes = capture.barcodes;
          if (barcodes.isNotEmpty) {
            if (mounted) {
              setState(() {
                _isScanPaused = true;
              });
            }

            final String code =
                barcodes.first.rawValue ?? AppLocalizations.of(context)!.noData;
            final String format = barcodes.first.format.name;
            final String result =
                '${DateFormat('yyyyMMdd_HHmmss').format(DateTime.now())},$format,$code';
            widget.onScan(result);

            if (settings.beepOnScan) {
              final soundId = settings.selectedBeepSound;
              AudioPlayer().play(AssetSource('sound/beep$soundId.mp3'));
            }

            if (settings.flashOnScan) {
              if (mounted) {
                setState(() {
                  _isFlashing = true;
                });
              }
            }

            if (settings.continuousScan) {
              if (settings.flashOnScan) {
                Timer(const Duration(milliseconds: 200), () {
                  if (mounted) {
                    setState(() {
                      _isFlashing = false;
                    });
                  }
                });
              }
              final interval = (settings.scanInterval * 1000).toInt();
              Timer(Duration(milliseconds: interval), () {
                if (mounted) {
                  setState(() {
                    _isScanPaused = false;
                  });
                }
              });
            } else {
              final popDelay = settings.flashOnScan ? 200 : 50;
              Timer(Duration(milliseconds: popDelay), () {
                if (mounted) {
                  Navigator.of(context).pop();
                }
              });
            }
          }
        },
      ),
    );
  }
}

lib/settings_page.dart

import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:provider/provider.dart';
import 'l10n/app_localizations.dart';

import 'package:barcodereader/ad_manager.dart';
import 'package:barcodereader/ad_banner_widget.dart';
import 'package:barcodereader/settings_provider.dart';

// 設定画面のUI (StatefulWidget)
class SettingsPage extends StatefulWidget {
  const SettingsPage({super.key});

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

class _SettingsPageState extends State<SettingsPage> {
  late AdManager _adManager;
  // 画面内でのみ変更を保持する一時的な変数
  late bool _tempContinuousScan;
  late bool _tempBeepOnScan;
  late bool _tempFlashOnScan;
  late bool _tempLockOrientation;
  late ThemeMode _tempThemeMode;
  late double _tempScanInterval;
  late int _tempSelectedBeepSound; // 一時変数
  late Locale? _tempAppLocale;

  final Map<String, String> languages = {
    'en': 'English',
    'bg': 'Български',
    'cs': 'Čeština',
    'da': 'Dansk',
    'de': 'Deutsch',
    'el': 'Ελληνικά',
    'es': 'Español',
    'et': 'Eesti',
    'fi': 'Suomi',
    'fr': 'Français',
    'hu': 'Magyar',
    'it': 'Italiano',
    'ja': '日本語',
    'lt': 'Lietuvių',
    'lv': 'Latviešu',
    'nl': 'Nederlands',
    'pl': 'Polski',
    'pt': 'Português',
    'ro': 'Română',
    'ru': 'Русский',
    'sk': 'Slovenčina',
    'sv': 'Svenska',
    'zh': '中文',
  };

  @override
  void initState() {
    super.initState();
    _adManager = AdManager();
    // Providerから初期値を取得して一時変数にセット
    final settings = context.read<SettingsProvider>();
    _tempContinuousScan = settings.continuousScan;
    _tempBeepOnScan = settings.beepOnScan;
    _tempFlashOnScan = settings.flashOnScan;
    _tempLockOrientation = settings.lockOrientation;
    _tempThemeMode = settings.themeMode;
    _tempScanInterval = settings.scanInterval;
    _tempSelectedBeepSound = settings.selectedBeepSound; // 初期値
    _tempAppLocale = settings.appLocale;
  }

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

  void _applyChanges() {
    // Provider経由で変更を保存
    final settings = context.read<SettingsProvider>();
    settings.setContinuousScan(_tempContinuousScan);
    settings.setBeepOnScan(_tempBeepOnScan);
    settings.setFlashOnScan(_tempFlashOnScan);
    settings.setLockOrientation(_tempLockOrientation);
    settings.setThemeMode(_tempThemeMode);
    settings.setScanInterval(_tempScanInterval);
    settings.setSelectedBeepSound(_tempSelectedBeepSound); // 保存
    settings.setAppLocale(_tempAppLocale);
    if (mounted) {
      Navigator.of(context).pop();
    }
  }

  @override
  Widget build(BuildContext context) {
    final l = AppLocalizations.of(context)!;
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        title: Text(l.settings),
        centerTitle: true,
        leading: IconButton(
          icon: const Icon(Icons.close),
          onPressed: () => Navigator.of(context).pop(),
        ),
        actions: [
          IconButton(icon: const Icon(Icons.check), onPressed: _applyChanges),
          const SizedBox(width: 24),
        ],
      ),
      body: ListView(
        children: [
          SwitchListTile(
            title: Text(l.continuousScan),
            value: _tempContinuousScan,
            onChanged: (value) => setState(() => _tempContinuousScan = value),
          ),
          Padding(
            padding: const EdgeInsets.symmetric(
              horizontal: 16.0,
              vertical: 8.0,
            ),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  l.scanInterval(
                    _tempScanInterval.toStringAsFixed(1),
                  ),
                ),
                Slider(
                  value: _tempScanInterval,
                  min: 0.0,
                  max: 3.0,
                  divisions: 15,
                  label: '${_tempScanInterval.toStringAsFixed(1)}s',
                  onChanged: (value) {
                    setState(() {
                      _tempScanInterval = value;
                    });
                  },
                ),
              ],
            ),
          ),
          const Divider(),
          SwitchListTile(
            title: Text(l.beepOnScan),
            value: _tempBeepOnScan,
            onChanged: (value) => setState(() => _tempBeepOnScan = value),
          ),
          // --- ビープ音選択 ---
          ListTile(
            title: Text(l.beepSound),
            trailing: DropdownButton<int>(
              value: _tempSelectedBeepSound,
              items: List.generate(14, (index) {
                final soundNum = index + 1;
                return DropdownMenuItem(
                  value: soundNum,
                  child: Text(
                    l.beepSoundName(
                      soundNum.toString(),
                    ),
                  ),
                );
              }),
              onChanged: (value) {
                if (value != null) {
                  setState(() => _tempSelectedBeepSound = value);
                  AudioPlayer().play(AssetSource('sound/beep$value.mp3'));
                }
              },
            ),
          ),
          SwitchListTile(
            title: Text(l.flashOnScan),
            value: _tempFlashOnScan,
            onChanged: (value) => setState(() => _tempFlashOnScan = value),
          ),
          const Divider(),
          SwitchListTile(
            title: Text(l.lockOrientation),
            value: _tempLockOrientation,
            onChanged: (value) => setState(() => _tempLockOrientation = value),
          ),
          const Divider(),
          ListTile(
            title: Text(l.language),
            trailing: DropdownButton<String>(
              value: _tempAppLocale?.languageCode,
              hint: Text(l.systemDefault),
              items: [
                DropdownMenuItem<String>(
                  value: null,
                  child: Text(l.systemDefault),
                ),
                ...languages.entries.map((entry) {
                  return DropdownMenuItem<String>(
                    value: entry.key,
                    child: Text(entry.value),
                  );
                }),
              ],
              onChanged: (value) {
                setState(() {
                  if (value == null) {
                    _tempAppLocale = null;
                  } else {
                    _tempAppLocale = Locale(value);
                  }
                });
              },
            ),
          ),
          ListTile(
            title: Text(l.theme),
            trailing: DropdownButton<ThemeMode>(
              value: _tempThemeMode,
              items: [
                DropdownMenuItem(
                  value: ThemeMode.system,
                  child: Text(l.systemDefault),
                ),
                DropdownMenuItem(
                  value: ThemeMode.light,
                  child: Text(l.lightTheme),
                ),
                DropdownMenuItem(
                  value: ThemeMode.dark,
                  child: Text(l.darkTheme),
                ),
              ],
              onChanged: (value) {
                if (value != null) {
                  setState(() => _tempThemeMode = value);
                }
              },
            ),
          ),
          const Divider(),
        ],
      ),
      bottomNavigationBar: AdBannerWidget(adManager: _adManager),
    );
  }

}

lib/settings_provider.dart

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

// 設定値を管理するためのProvider
class SettingsProvider extends ChangeNotifier {
  bool _continuousScan = false;
  bool _beepOnScan = true;
  bool _flashOnScan = true;
  bool _lockOrientation = false;
  ThemeMode _themeMode = ThemeMode.system;
  double _scanInterval = 1.0;
  int _selectedBeepSound = 1; // ビープ音の選択値
  Locale? _appLocale;

  bool get continuousScan => _continuousScan;
  bool get beepOnScan => _beepOnScan;
  bool get flashOnScan => _flashOnScan;
  bool get lockOrientation => _lockOrientation;
  ThemeMode get themeMode => _themeMode;
  double get scanInterval => _scanInterval;
  int get selectedBeepSound => _selectedBeepSound; // getter
  Locale? get appLocale => _appLocale;

  static const String _continuousScanKey = 'continuousScan';
  static const String _beepOnScanKey = 'beepOnScan';
  static const String _flashOnScanKey = 'flashOnScan';
  static const String _lockOrientationKey = 'lockOrientation';
  static const String _themeModeKey = 'themeMode';
  static const String _scanIntervalKey = 'scanInterval';
  static const String _selectedBeepSoundKey = 'selectedBeepSound'; // キー
  static const String _appLocaleKey = 'appLocale';

  SettingsProvider() {
    _loadSettings();
  }

  // 設定をSharedPreferencesから読み込む
  Future<void> _loadSettings() async {
    final prefs = await SharedPreferences.getInstance();
    _continuousScan = prefs.getBool(_continuousScanKey) ?? false;
    _beepOnScan = prefs.getBool(_beepOnScanKey) ?? true;
    _flashOnScan = prefs.getBool(_flashOnScanKey) ?? true;
    _lockOrientation = prefs.getBool(_lockOrientationKey) ?? false;
    final themeIndex = prefs.getInt(_themeModeKey) ?? 0;
    _themeMode = ThemeMode.values[themeIndex];
    _scanInterval = prefs.getDouble(_scanIntervalKey) ?? 1.0;
    _selectedBeepSound = prefs.getInt(_selectedBeepSoundKey) ?? 1; // 読み込み
    final languageCode = prefs.getString(_appLocaleKey);
    if (languageCode != null) {
      _appLocale = Locale(languageCode);
    }
    notifyListeners();
  }

  // 各設定値を更新し、SharedPreferencesに保存
  Future<void> setContinuousScan(bool value) async {
    _continuousScan = value;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setBool(_continuousScanKey, value);
    notifyListeners();
  }

  Future<void> setBeepOnScan(bool value) async {
    _beepOnScan = value;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setBool(_beepOnScanKey, value);
    notifyListeners();
  }

  Future<void> setFlashOnScan(bool value) async {
    _flashOnScan = value;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setBool(_flashOnScanKey, value);
    notifyListeners();
  }

  Future<void> setLockOrientation(bool value) async {
    _lockOrientation = value;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setBool(_lockOrientationKey, value);
    notifyListeners();
  }

  Future<void> setThemeMode(ThemeMode value) async {
    _themeMode = value;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setInt(_themeModeKey, value.index);
    notifyListeners();
  }

  Future<void> setScanInterval(double value) async {
    _scanInterval = value;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setDouble(_scanIntervalKey, value);
    notifyListeners();
  }

  Future<void> setSelectedBeepSound(int value) async {
    // setter
    _selectedBeepSound = value;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setInt(_selectedBeepSoundKey, value);
    notifyListeners();
  }

  Future<void> setAppLocale(Locale? locale) async {
    _appLocale = locale;
    final prefs = await SharedPreferences.getInstance();
    if (locale == null) {
      await prefs.remove(_appLocaleKey);
    } else {
      await prefs.setString(_appLocaleKey, locale.languageCode);
    }
    notifyListeners();
  }
}