import 'dart:io'; import 'dart:convert'; import 'package:flutter/material.dart'; // camera を使う(内蔵カメラ画面) import 'package:camera/camera.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; import 'package:http/http.dart' as http; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await _requestPermissions(); runApp(const MyApp()); } /// カメラとストレージのパーミッション要求 Future _requestPermissions() async { if (Platform.isAndroid) { await Permission.camera.request(); await Permission.storage.request(); } else if (Platform.isIOS) { await Permission.camera.request(); await Permission.photos.request(); } } /// メインアプリ class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: '説明書検索アプリ', theme: ThemeData(useMaterial3: true), home: const OCRSearchScreen(), ); } } /// OCRと検索画面 class OCRSearchScreen extends StatefulWidget { const OCRSearchScreen({super.key}); @override State createState() => _OCRSearchScreenState(); } class _OCRSearchScreenState extends State { File? _image; String _selectedModel = ''; List> _searchResults = []; List> _favorites = []; bool _isLoading = false; bool _showAllResults = false; // 検索バー用 final TextEditingController _manualController = TextEditingController(); @override void initState() { super.initState(); _loadFavorites(); } @override void dispose() { _manualController.dispose(); super.dispose(); } Future _loadFavorites() async { final prefs = await SharedPreferences.getInstance(); final favs = prefs.getStringList("favorites") ?? []; if (!mounted) return; setState(() { _favorites = favs .map((e) => Map.from(json.decode(e))) .toList(); }); } Future _saveFavorites() async { final prefs = await SharedPreferences.getInstance(); final favs = _favorites.map((e) => json.encode(e)).toList(); await prefs.setStringList("favorites", favs); } /// 画像を撮影→OCR→検索 Future _pickImageAndRecognizeText() async { // 内蔵カメラ画面へ final XFile? captured = await Navigator.push( context, MaterialPageRoute(builder: (_) => const CameraCapturePage()), ); if (captured == null) return; setState(() { _isLoading = true; _image = File(captured.path); _selectedModel = ''; _searchResults = []; _showAllResults = false; }); try { final inputImage = InputImage.fromFile(_image!); final textRecognizer = TextRecognizer(); final recognizedText = await textRecognizer.processImage(inputImage); await textRecognizer.close(); final models = _extractModelNumbers(recognizedText.text); if (models.isNotEmpty) { final first = models.first; if (!mounted) return; setState(() { _selectedModel = first; _manualController.text = first; // 取得した型番を検索バーに反映 }); await _searchManuals(first); } else { if (!mounted) return; setState(() { _selectedModel = ''; }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('型番が見つかりませんでした。検索バーから手入力してください。')), ); } } catch (e) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('エラー: $e'))); } finally { if (!mounted) return; setState(() { _isLoading = false; }); } } /// 型番候補を抽出(英字のみ/数字のみ除外、5〜20文字) List _extractModelNumbers(String text) { final modelRegex = RegExp(r'\b[A-Z0-9][A-Z0-9\-_]{4,19}\b'); final matches = modelRegex.allMatches(text); final candidates = matches.map((m) => m.group(0)!).toSet().toList(); final filtered = candidates.where((c) { final hasLetter = RegExp(r'[A-Z]').hasMatch(c); final hasNumber = RegExp(r'\d').hasMatch(c); return hasLetter && hasNumber && c.length >= 5 && c.length <= 20; }).toList(); filtered.sort( (a, b) => _scoreModelCandidate(b).compareTo(_scoreModelCandidate(a)), ); return filtered; } int _scoreModelCandidate(String model) { int score = 0; if (model.length >= 6 && model.length <= 12) score += 1; if (RegExp(r'[A-Z]').hasMatch(model) && RegExp(r'\d').hasMatch(model) && RegExp(r'[-_]').hasMatch(model)) { score += 1; } if (RegExp(r'^[A-Z]').hasMatch(model)) score += 1; return score; } /// Google カスタム検索 API を使って説明書を検索 Future _searchManuals(String modelNumber) async { const apiKey = 'AIzaSyAEpUlGntFdDygHBgleEmig6gleFnvcEPQ'; // ←あなたのキー const searchEngineId = '41e4997a6d32d42b7'; // ←あなたのCSE ID final query = Uri.encodeComponent('$modelNumber 説明書 PDF'); final url = Uri.parse( 'https://www.googleapis.com/customsearch/v1?key=$apiKey&cx=$searchEngineId&q=$query', ); final response = await http.get(url); if (response.statusCode == 200) { final data = json.decode(response.body); final items = data['items'] as List?; if (items != null) { final results = items.map((item) { final link = item['link'].toString(); final title = item['title'].toString(); return {'link': link, 'title': title}; }).toList(); results.sort( (a, b) => _scoreHostHints( b['link']!, b['title']!, ).compareTo(_scoreHostHints(a['link']!, a['title']!)), ); if (!mounted) return; setState(() { _searchResults = results; }); } else { if (!mounted) return; setState(() => _searchResults = []); } } else { debugPrint('検索エラー: ${response.statusCode}'); if (!mounted) return; setState(() => _searchResults = []); } } int _scoreHostHints(String url, String title) { final lowerUrl = url.toLowerCase(); final lowerTitle = title.toLowerCase(); final officialWords = [ "support", "downloads", "manual", "docs", "help", "service", "global", "jp", "downloadcenter", "drivers", "product-support", "customer-support", "knowledgebase", "faq", "manuals", ]; final nonOfficialWords = [ "amazon", "rakuten", "yahoo", "mercari", "kakaku", "store", "shop", "auction", ]; int score = 0; if (lowerUrl.endsWith(".pdf")) score += 5; for (final word in officialWords) { if (lowerUrl.contains(word) || lowerTitle.contains(word)) { score += 3; } } for (final word in nonOfficialWords) { if (lowerUrl.contains(word) || lowerTitle.contains(word)) { score -= 3; } } return score; } /// お気に入り登録時に名前入力 Future _askFavoriteName( BuildContext context, String defaultName, ) async { String customName = ''; await showDialog( context: context, builder: (context) { return AlertDialog( title: const Text("お気に入りに名前を付ける"), content: TextField( autofocus: true, decoration: const InputDecoration( hintText: "例: 掃除機の説明書(未入力なら自動保存)", ), onChanged: (value) => customName = value, ), actions: [ TextButton( child: const Text("キャンセル"), onPressed: () => Navigator.pop(context), ), TextButton( child: const Text("保存"), onPressed: () => Navigator.pop(context), ), ], ); }, ); return customName.isEmpty ? defaultName : customName; } /// お気に入り登録/解除 void _toggleFavorite(BuildContext context, Map item) async { final exists = _favorites.any((f) => f['link'] == item['link']); if (exists) { setState(() { _favorites.removeWhere((f) => f['link'] == item['link']); }); _saveFavorites(); return; } final customName = await _askFavoriteName(context, item['title'] ?? "説明書"); if (!mounted) return; final favItem = { 'link': item['link']!, 'title': item['title']!, 'customName': customName!, }; setState(() { _favorites.add(favItem); }); _saveFavorites(); } /// 手動検索 Future _searchByManualInput() async { final input = _manualController.text.trim(); if (input.isEmpty) return; setState(() { _selectedModel = input; _searchResults = []; _showAllResults = false; }); await _searchManuals(input); } @override Widget build(BuildContext context) { final resultsToShow = _showAllResults ? _searchResults : _searchResults.take(5).toList(); return Scaffold( appBar: AppBar( title: const Text('説明書検索アプリ'), actions: [ TextButton.icon( onPressed: () async { await Navigator.push( context, MaterialPageRoute( builder: (_) => FavoritesPage( favorites: _favorites, onUpdate: (favs) { setState(() => _favorites = favs); _saveFavorites(); }, ), ), ); }, icon: const Icon(Icons.star, color: Colors.orange), label: const Text('お気に入り', style: TextStyle(color: Colors.orange)), ), const SizedBox(width: 8), ], ), // 画面下中央にカメラボタン(FAB) floatingActionButton: FloatingActionButton( onPressed: _pickImageAndRecognizeText, backgroundColor: Colors.blue, child: const Icon(Icons.camera_alt, size: 32, color: Colors.white), ), floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, body: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 12, 16, 90), // FABと被らないように下余白 child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 常時表示の検索バー TextField( controller: _manualController, textInputAction: TextInputAction.search, onSubmitted: (_) => _searchByManualInput(), onChanged: (_) => setState(() {}), // ×ボタンの表示を即時反映 decoration: InputDecoration( hintText: '型番を手入力して検索(例:SPF-013)', prefixIcon: const Icon(Icons.search), suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ if (_manualController.text.isNotEmpty) IconButton( icon: const Icon(Icons.clear), onPressed: () { setState(() { _manualController.clear(); }); }, ), IconButton( icon: const Icon(Icons.arrow_forward), onPressed: _searchByManualInput, ), ], ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), filled: true, ), ), const SizedBox(height: 12), if (_isLoading) const Center(child: CircularProgressIndicator()), if (_image != null) ...[ Image.file(_image!), const SizedBox(height: 16), ], if (_selectedModel.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 8), child: Text('抽出された型番(自動選択): $_selectedModel'), ), if (_searchResults.isNotEmpty) ...[ const Text('検索結果:'), const SizedBox(height: 8), ...resultsToShow.map((result) { final link = result['link']!; final title = result['title']!; final isPdf = link.toLowerCase().endsWith(".pdf"); final isFav = _favorites.any((f) => f['link'] == link); return Card( margin: const EdgeInsets.symmetric(vertical: 6), child: ListTile( leading: Icon( isPdf ? Icons.picture_as_pdf : Icons.language, color: isPdf ? Colors.red : Colors.blue, ), title: Text(isPdf ? "📄 PDF: $title" : "🌐 Web: $title"), trailing: IconButton( icon: Icon( isFav ? Icons.star : Icons.star_border, color: Colors.orange, ), onPressed: () => _toggleFavorite(context, result), ), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (_) => isPdf ? PDFViewerPage(url: link) : WebViewerPage(url: link), ), ); }, ), ); }), if (!_showAllResults && _searchResults.length > 5) TextButton( onPressed: () => setState(() => _showAllResults = true), child: const Text("もっと見る"), ), ], ], ), ), ); } } /// お気に入り一覧画面 class FavoritesPage extends StatefulWidget { final List> favorites; final Function(List>) onUpdate; const FavoritesPage({ super.key, required this.favorites, required this.onUpdate, }); @override State createState() => _FavoritesPageState(); } class _FavoritesPageState extends State { late List> _favorites; @override void initState() { super.initState(); _favorites = List.from(widget.favorites); } Future _askFavoriteName( BuildContext context, String defaultName, ) async { String customName = ''; await showDialog( context: context, builder: (context) { return AlertDialog( title: const Text("名前を変更"), content: TextField( autofocus: true, decoration: const InputDecoration(hintText: "新しい名前"), onChanged: (value) => customName = value, ), actions: [ TextButton( child: const Text("キャンセル"), onPressed: () => Navigator.pop(context), ), TextButton( child: const Text("保存"), onPressed: () => Navigator.pop(context), ), ], ); }, ); return customName.isEmpty ? defaultName : customName; } Future _confirmDelete(BuildContext context) async { return showDialog( context: context, builder: (context) { return AlertDialog( title: const Text("削除しますか?"), actions: [ TextButton( child: const Text("いいえ"), onPressed: () => Navigator.pop(context, false), ), TextButton( child: const Text("はい"), onPressed: () => Navigator.pop(context, true), ), ], ); }, ); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("お気に入り一覧")), body: ListView.builder( itemCount: _favorites.length, itemBuilder: (context, index) { final item = _favorites[index]; final link = item['link']!; final title = item['title']!; final customName = item['customName'] ?? title; final isPdf = link.toLowerCase().endsWith(".pdf"); return Card( margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), child: ListTile( leading: Icon( isPdf ? Icons.picture_as_pdf : Icons.language, color: isPdf ? Colors.red : Colors.blue, ), title: Text(customName), subtitle: Text( title, maxLines: 1, overflow: TextOverflow.ellipsis, ), trailing: PopupMenuButton( onSelected: (value) async { if (value == 'edit') { final newName = await _askFavoriteName(context, customName); if (!mounted) return; setState(() { _favorites[index]['customName'] = newName!; }); widget.onUpdate(_favorites); } else if (value == 'delete') { final ok = await _confirmDelete(context); if (ok == true) { if (!mounted) return; setState(() { _favorites.removeAt(index); }); widget.onUpdate(_favorites); } } }, itemBuilder: (context) => const [ PopupMenuItem(value: 'edit', child: Text("✏️ 名前を変更")), PopupMenuItem(value: 'delete', child: Text("🗑 削除")), ], ), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (_) => isPdf ? PDFViewerPage(url: link) : WebViewerPage(url: link), ), ); }, ), ); }, ), ); } } /// ===== 内蔵カメラ画面(シャッター下固定) ===== class CameraCapturePage extends StatefulWidget { const CameraCapturePage({super.key}); @override State createState() => _CameraCapturePageState(); } class _CameraCapturePageState extends State { CameraController? _controller; List _cameras = []; bool _isBusy = true; bool _torchOn = false; int _index = 0; // 0:背面→1:前面 @override void initState() { super.initState(); _initCameras(); } Future _initCameras() async { try { _cameras = await availableCameras(); if (_cameras.isEmpty) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('カメラが見つかりませんでした'))); Navigator.pop(context); return; } _index = _cameras.indexWhere( (c) => c.lensDirection == CameraLensDirection.back, ); if (_index < 0) _index = 0; await _startController(_cameras[_index]); } catch (e) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('カメラ初期化エラー: $e'))); Navigator.pop(context); } } Future _startController(CameraDescription desc) async { _controller?.dispose(); _controller = CameraController(desc, ResolutionPreset.medium); await _controller!.initialize(); if (!mounted) return; setState(() { _isBusy = false; }); } Future _toggleTorch() async { if (_controller == null) return; _torchOn = !_torchOn; await _controller!.setFlashMode(_torchOn ? FlashMode.torch : FlashMode.off); if (!mounted) return; setState(() {}); } Future _switchCamera() async { if (_cameras.length < 2) return; _index = (_index + 1) % _cameras.length; setState(() => _isBusy = true); await _startController(_cameras[_index]); } @override void dispose() { _controller?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final preview = (_controller != null && _controller!.value.isInitialized) ? CameraPreview(_controller!) : const SizedBox.shrink(); return Scaffold( backgroundColor: Colors.black, body: SafeArea( child: Stack( children: [ Positioned.fill(child: preview), // 下部コントロールバー(シャッター固定) Positioned( left: 0, right: 0, bottom: 0, child: Container( padding: const EdgeInsets.symmetric( horizontal: 24, vertical: 16, ), decoration: BoxDecoration( gradient: LinearGradient( colors: [ Colors.black.withOpacity(0.0), Colors.black.withOpacity(0.6), Colors.black.withOpacity(0.9), ], begin: Alignment.topCenter, end: Alignment.bottomCenter, ), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // トーチ IconButton( icon: Icon( _torchOn ? Icons.flash_on : Icons.flash_off, color: Colors.white, size: 28, ), onPressed: _toggleTorch, ), // シャッター(大きい円) GestureDetector( onTap: _isBusy ? null : () async { try { setState(() => _isBusy = true); final file = await _controller!.takePicture(); if (!mounted) return; Navigator.pop(context, file); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('撮影エラー: $e')), ); setState(() => _isBusy = false); } }, child: Container( width: 76, height: 76, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 4), color: Colors.white.withOpacity(0.15), ), child: const Center( child: CircleAvatar( radius: 28, backgroundColor: Colors.white, ), ), ), ), // カメラ切替 IconButton( icon: const Icon( Icons.cameraswitch, color: Colors.white, size: 28, ), onPressed: _switchCamera, ), ], ), ), ), if (_isBusy) const Positioned.fill( child: IgnorePointer( ignoring: true, child: Center( child: CircularProgressIndicator(color: Colors.white), ), ), ), // 戻るボタン(左上) Positioned( left: 8, top: 8, child: IconButton( icon: const Icon(Icons.close, color: Colors.white), onPressed: () => Navigator.pop(context), ), ), ], ), ), ); } } /// PDFビューア画面 class PDFViewerPage extends StatelessWidget { final String url; const PDFViewerPage({super.key, required this.url}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("PDFビューア")), body: SfPdfViewer.network(url), ); } } /// Webビューア画面 class WebViewerPage extends StatelessWidget { final String url; const WebViewerPage({super.key, required this.url}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("Webマニュアル")), body: WebViewWidget( controller: WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..loadRequest(Uri.parse(url)), ), ); } }