Initial commit

This commit is contained in:
2025-10-28 14:16:07 +09:00
commit f0ba102a37
131 changed files with 7064 additions and 0 deletions

855
lib/main.dart Normal file
View File

@@ -0,0 +1,855 @@
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)),
),
);
}
}