856 lines
26 KiB
Dart
856 lines
26 KiB
Dart
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)),
|
||
),
|
||
);
|
||
}
|
||
}
|