home_screen.dart 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. import 'package:flutter/material.dart';
  2. import '../services/database_helper.dart';
  3. import '../services/app_strings.dart';
  4. import '../theme/theme_controller.dart';
  5. import '../theme/locale_controller.dart';
  6. import '../widgets/brand_mark.dart';
  7. import 'analysis_screen.dart';
  8. import 'live_analysis_screen.dart';
  9. import 'static_capture_screen.dart';
  10. import 'history_screen.dart';
  11. import 'manual_screen.dart';
  12. class HomeScreen extends StatefulWidget {
  13. const HomeScreen({super.key});
  14. @override
  15. State<HomeScreen> createState() => _HomeScreenState();
  16. }
  17. class _HomeScreenState extends State<HomeScreen> {
  18. int? _totalScans;
  19. @override
  20. void initState() {
  21. super.initState();
  22. _loadStats();
  23. }
  24. Future<void> _loadStats() async {
  25. final records = await DatabaseHelper().getAllRecords();
  26. if (mounted) setState(() => _totalScans = records.length);
  27. }
  28. @override
  29. Widget build(BuildContext context) {
  30. final colorScheme = Theme.of(context).colorScheme;
  31. return ValueListenableBuilder<String>(
  32. valueListenable: LocaleController.localeCode,
  33. builder: (context, _, _) {
  34. return Scaffold(
  35. body: SafeArea(
  36. child: SingleChildScrollView(
  37. padding: const EdgeInsets.all(20),
  38. child: Column(
  39. crossAxisAlignment: CrossAxisAlignment.start,
  40. children: [
  41. Row(
  42. children: [
  43. const BrandMark(size: 44),
  44. const SizedBox(width: 12),
  45. Expanded(
  46. child: Text(
  47. AppStrings.t('app_title'),
  48. style: TextStyle(
  49. fontSize: 20,
  50. fontWeight: FontWeight.bold,
  51. color: colorScheme.onSurface,
  52. ),
  53. ),
  54. ),
  55. IconButton(
  56. icon: Icon(Icons.help_outline, color: colorScheme.onSurface),
  57. tooltip: AppStrings.t('manual_title'),
  58. onPressed: () => Navigator.push(
  59. context,
  60. MaterialPageRoute(builder: (context) => const ManualScreen()),
  61. ),
  62. ),
  63. IconButton(
  64. icon: Icon(Icons.settings_outlined, color: colorScheme.onSurface),
  65. tooltip: AppStrings.t('settings_label'),
  66. onPressed: () => _showSettingsSheet(context),
  67. ),
  68. ],
  69. ),
  70. const SizedBox(height: 20),
  71. _buildStatCard(context, colorScheme),
  72. const SizedBox(height: 24),
  73. GridView.count(
  74. shrinkWrap: true,
  75. physics: const NeverScrollableScrollPhysics(),
  76. crossAxisCount: 2,
  77. mainAxisSpacing: 16,
  78. crossAxisSpacing: 16,
  79. childAspectRatio: 0.95,
  80. children: [
  81. _buildTile(
  82. context,
  83. title: AppStrings.t('analyze_gallery_title'),
  84. subtitle: AppStrings.t('analyze_gallery_subtitle'),
  85. icon: Icons.photo_library,
  86. background: colorScheme.primary,
  87. foreground: colorScheme.onPrimary,
  88. onTap: () => Navigator.push(
  89. context,
  90. MaterialPageRoute(builder: (context) => const AnalysisScreen()),
  91. ),
  92. ),
  93. _buildTile(
  94. context,
  95. title: AppStrings.t('snap_analyze_title'),
  96. subtitle: AppStrings.t('snap_analyze_subtitle'),
  97. icon: Icons.camera_alt,
  98. background: colorScheme.secondary,
  99. foreground: colorScheme.onSecondary,
  100. onTap: () => Navigator.push(
  101. context,
  102. MaterialPageRoute(builder: (context) => const StaticCaptureScreen()),
  103. ),
  104. ),
  105. _buildTile(
  106. context,
  107. title: AppStrings.t('live_inference_title'),
  108. subtitle: AppStrings.t('live_inference_subtitle'),
  109. icon: Icons.camera,
  110. background: colorScheme.primaryContainer,
  111. foreground: colorScheme.onPrimaryContainer,
  112. onTap: () => Navigator.push(
  113. context,
  114. MaterialPageRoute(builder: (context) => const LiveAnalysisScreen()),
  115. ),
  116. ),
  117. _buildTile(
  118. context,
  119. title: AppStrings.t('history_vault_title'),
  120. subtitle: AppStrings.t('history_vault_subtitle'),
  121. icon: Icons.history,
  122. background: colorScheme.surfaceContainerHighest,
  123. foreground: colorScheme.onSurface,
  124. bordered: true,
  125. onTap: () => Navigator.push(
  126. context,
  127. MaterialPageRoute(builder: (context) => const HistoryScreen()),
  128. ),
  129. ),
  130. ],
  131. ),
  132. ],
  133. ),
  134. ),
  135. ),
  136. );
  137. },
  138. );
  139. }
  140. Future<void> _showSettingsSheet(BuildContext context) async {
  141. await showModalBottomSheet(
  142. context: context,
  143. shape: const RoundedRectangleBorder(
  144. borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
  145. ),
  146. builder: (sheetContext) {
  147. return SafeArea(
  148. child: Column(
  149. mainAxisSize: MainAxisSize.min,
  150. children: [
  151. Padding(
  152. padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
  153. child: Align(
  154. alignment: Alignment.centerLeft,
  155. child: Text(AppStrings.t('settings_label'), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
  156. ),
  157. ),
  158. ValueListenableBuilder<ThemeMode>(
  159. valueListenable: ThemeController.themeMode,
  160. builder: (context, mode, _) {
  161. return ListTile(
  162. leading: Icon(_themeModeIcon(mode)),
  163. title: Text(AppStrings.t('theme_label')),
  164. subtitle: Text(_themeModeLabel(mode)),
  165. trailing: const Icon(Icons.chevron_right),
  166. onTap: () {
  167. Navigator.pop(sheetContext);
  168. _showThemePicker(context, mode);
  169. },
  170. );
  171. },
  172. ),
  173. ValueListenableBuilder<String>(
  174. valueListenable: LocaleController.localeCode,
  175. builder: (context, code, _) {
  176. return ListTile(
  177. leading: const Icon(Icons.language),
  178. title: Text(AppStrings.t('language_label')),
  179. subtitle: Text(_languageLabel(code)),
  180. trailing: const Icon(Icons.chevron_right),
  181. onTap: () {
  182. Navigator.pop(sheetContext);
  183. _showLanguagePicker(context);
  184. },
  185. );
  186. },
  187. ),
  188. const SizedBox(height: 8),
  189. ],
  190. ),
  191. );
  192. },
  193. );
  194. }
  195. String _themeModeLabel(ThemeMode mode) {
  196. switch (mode) {
  197. case ThemeMode.light:
  198. return AppStrings.t('theme_light');
  199. case ThemeMode.dark:
  200. return AppStrings.t('theme_dark');
  201. case ThemeMode.system:
  202. return AppStrings.t('theme_system');
  203. }
  204. }
  205. String _languageLabel(String code) {
  206. switch (code) {
  207. case 'ms':
  208. return "Bahasa Melayu";
  209. case 'zh':
  210. return "中文";
  211. default:
  212. return "English";
  213. }
  214. }
  215. IconData _themeModeIcon(ThemeMode mode) {
  216. switch (mode) {
  217. case ThemeMode.light:
  218. return Icons.light_mode;
  219. case ThemeMode.dark:
  220. return Icons.dark_mode;
  221. case ThemeMode.system:
  222. return Icons.brightness_auto;
  223. }
  224. }
  225. Future<void> _showThemePicker(BuildContext context, ThemeMode current) async {
  226. await showModalBottomSheet(
  227. context: context,
  228. shape: const RoundedRectangleBorder(
  229. borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
  230. ),
  231. builder: (sheetContext) {
  232. return SafeArea(
  233. child: Column(
  234. mainAxisSize: MainAxisSize.min,
  235. children: [
  236. Padding(
  237. padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
  238. child: Align(
  239. alignment: Alignment.centerLeft,
  240. child: Text(AppStrings.t('theme_label'), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
  241. ),
  242. ),
  243. _themeOptionTile(sheetContext, AppStrings.t('theme_light'), Icons.light_mode, ThemeMode.light, current),
  244. _themeOptionTile(sheetContext, AppStrings.t('theme_dark'), Icons.dark_mode, ThemeMode.dark, current),
  245. _themeOptionTile(sheetContext, AppStrings.t('theme_system'), Icons.brightness_auto, ThemeMode.system, current),
  246. const SizedBox(height: 8),
  247. ],
  248. ),
  249. );
  250. },
  251. );
  252. }
  253. Widget _themeOptionTile(BuildContext context, String label, IconData icon, ThemeMode mode, ThemeMode current) {
  254. final selected = mode == current;
  255. return ListTile(
  256. leading: Icon(icon),
  257. title: Text(label),
  258. trailing: selected ? const Icon(Icons.check) : null,
  259. onTap: () {
  260. ThemeController.setThemeMode(mode);
  261. Navigator.pop(context);
  262. },
  263. );
  264. }
  265. Future<void> _showLanguagePicker(BuildContext context) async {
  266. final current = LocaleController.localeCode.value;
  267. await showModalBottomSheet(
  268. context: context,
  269. shape: const RoundedRectangleBorder(
  270. borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
  271. ),
  272. builder: (sheetContext) {
  273. return SafeArea(
  274. child: Column(
  275. mainAxisSize: MainAxisSize.min,
  276. children: [
  277. Padding(
  278. padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
  279. child: Align(
  280. alignment: Alignment.centerLeft,
  281. child: Text(AppStrings.t('language_label'), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
  282. ),
  283. ),
  284. _languageOptionTile(sheetContext, "English", "en", current),
  285. _languageOptionTile(sheetContext, "Bahasa Melayu", "ms", current),
  286. _languageOptionTile(sheetContext, "中文", "zh", current),
  287. const SizedBox(height: 8),
  288. ],
  289. ),
  290. );
  291. },
  292. );
  293. }
  294. Widget _languageOptionTile(BuildContext context, String label, String code, String current) {
  295. final selected = code == current;
  296. return ListTile(
  297. title: Text(label),
  298. trailing: selected ? const Icon(Icons.check) : null,
  299. onTap: () {
  300. LocaleController.setLocale(code);
  301. Navigator.pop(context);
  302. },
  303. );
  304. }
  305. Widget _buildStatCard(BuildContext context, ColorScheme colorScheme) {
  306. return InkWell(
  307. borderRadius: BorderRadius.circular(16),
  308. onTap: () => Navigator.push(
  309. context,
  310. MaterialPageRoute(builder: (context) => const HistoryScreen()),
  311. ),
  312. child: Container(
  313. width: double.infinity,
  314. padding: const EdgeInsets.all(16),
  315. decoration: BoxDecoration(
  316. color: colorScheme.surfaceContainerHighest,
  317. borderRadius: BorderRadius.circular(16),
  318. border: Border.all(color: colorScheme.primary.withValues(alpha: 0.15)),
  319. ),
  320. child: Row(
  321. children: [
  322. Icon(Icons.analytics_outlined, color: colorScheme.primary, size: 28),
  323. const SizedBox(width: 12),
  324. Column(
  325. crossAxisAlignment: CrossAxisAlignment.start,
  326. children: [
  327. Text(
  328. _totalScans == null ? '—' : '$_totalScans',
  329. style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: colorScheme.onSurface),
  330. ),
  331. Text(
  332. AppStrings.t('total_scans'),
  333. style: TextStyle(fontSize: 13, color: colorScheme.onSurface.withValues(alpha: 0.6)),
  334. ),
  335. ],
  336. ),
  337. const Spacer(),
  338. Icon(Icons.chevron_right, color: colorScheme.onSurface.withValues(alpha: 0.4)),
  339. ],
  340. ),
  341. ),
  342. );
  343. }
  344. Widget _buildTile(
  345. BuildContext context, {
  346. required String title,
  347. required String subtitle,
  348. required IconData icon,
  349. required Color background,
  350. required Color foreground,
  351. required VoidCallback onTap,
  352. bool bordered = false,
  353. }) {
  354. return GestureDetector(
  355. onTap: onTap,
  356. child: Container(
  357. padding: const EdgeInsets.all(16),
  358. decoration: BoxDecoration(
  359. color: background,
  360. borderRadius: BorderRadius.circular(20),
  361. border: bordered ? Border.all(color: foreground.withValues(alpha: 0.15)) : null,
  362. ),
  363. child: Column(
  364. crossAxisAlignment: CrossAxisAlignment.start,
  365. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  366. children: [
  367. Icon(icon, color: foreground, size: 32),
  368. Column(
  369. crossAxisAlignment: CrossAxisAlignment.start,
  370. children: [
  371. Text(
  372. title,
  373. style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: foreground),
  374. ),
  375. const SizedBox(height: 4),
  376. Text(
  377. subtitle,
  378. style: TextStyle(fontSize: 12, color: foreground.withValues(alpha: 0.85)),
  379. ),
  380. ],
  381. ),
  382. ],
  383. ),
  384. ),
  385. );
  386. }
  387. }