Files
22e1349-sotugyouseisaku/lib/main.dart
2025-10-28 14:16:07 +09:00

856 lines
26 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> _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<OCRSearchScreen> createState() => _OCRSearchScreenState();
}
class _OCRSearchScreenState extends State<OCRSearchScreen> {
File? _image;
String _selectedModel = '';
List<Map<String, String>> _searchResults = [];
List<Map<String, String>> _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<void> _loadFavorites() async {
final prefs = await SharedPreferences.getInstance();
final favs = prefs.getStringList("favorites") ?? [];
if (!mounted) return;
setState(() {
_favorites = favs
.map((e) => Map<String, String>.from(json.decode(e)))
.toList();
});
}
Future<void> _saveFavorites() async {
final prefs = await SharedPreferences.getInstance();
final favs = _favorites.map((e) => json.encode(e)).toList();
await prefs.setStringList("favorites", favs);
}
/// 画像を撮影→OCR→検索
Future<void> _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<String> _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<void> _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<dynamic>?;
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<String?> _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<String, String> 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<void> _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<Map<String, String>> favorites;
final Function(List<Map<String, String>>) onUpdate;
const FavoritesPage({
super.key,
required this.favorites,
required this.onUpdate,
});
@override
State<FavoritesPage> createState() => _FavoritesPageState();
}
class _FavoritesPageState extends State<FavoritesPage> {
late List<Map<String, String>> _favorites;
@override
void initState() {
super.initState();
_favorites = List.from(widget.favorites);
}
Future<String?> _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<bool?> _confirmDelete(BuildContext context) async {
return showDialog<bool>(
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<String>(
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<CameraCapturePage> createState() => _CameraCapturePageState();
}
class _CameraCapturePageState extends State<CameraCapturePage> {
CameraController? _controller;
List<CameraDescription> _cameras = [];
bool _isBusy = true;
bool _torchOn = false;
int _index = 0; // 0:背面→1:前面
@override
void initState() {
super.initState();
_initCameras();
}
Future<void> _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<void> _startController(CameraDescription desc) async {
_controller?.dispose();
_controller = CameraController(desc, ResolutionPreset.medium);
await _controller!.initialize();
if (!mounted) return;
setState(() {
_isBusy = false;
});
}
Future<void> _toggleTorch() async {
if (_controller == null) return;
_torchOn = !_torchOn;
await _controller!.setFlashMode(_torchOn ? FlashMode.torch : FlashMode.off);
if (!mounted) return;
setState(() {});
}
Future<void> _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)),
),
);
}
}