diff --git a/.github/workflows/flutter-ci.yml b/.github/workflows/flutter-ci.yml new file mode 100644 index 0000000..df45b95 --- /dev/null +++ b/.github/workflows/flutter-ci.yml @@ -0,0 +1,72 @@ + +name: Flutter CI + +on: + push: + branches: + - main + - feat/polish + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + dart-analyze: + name: dart analyze + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Analyze Dart + run: dart analyze + + flutter-analyze: + name: flutter analyze + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Analyze Flutter project + run: flutter analyze + + flutter-test: + name: flutter test + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Run tests + run: flutter test diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4f2e372..8b2a7f4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } android { - namespace = "com.example.playmate" + namespace = "com.sundramdotdev.playmate" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -21,7 +21,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.playmate" + applicationId = "com.sundramdotdev.playmate" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f6d408d..f5d9e10 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ 99.5\%$ of active global Android devices | | **Target Android Version** | **Android 14 / 15 (API Level 34/35)**| Complies with Google Play API target requirements | | **Minimum iOS Version** | **iOS 14.0** | Compatible with iPhone 6s through current iPhone generation | diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 5f8e4ee..773c3e4 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -95,7 +95,7 @@ build/app/outputs/bundle/release/app-release.aab ## 🍎 iOS Deployment Pipeline ### 1. Apple Developer Portal Setup -1. Create App Identifier: `com.example.playmate` (or commercial bundle ID `com.playmate.offline`). +1. Create App Identifier: `com.sundramdotdev.playmate` (matching application bundle ID). 2. Generate an **Apple Distribution Certificate** in Xcode or Apple Developer Portal. 3. Create an **App Store Provisioning Profile** tied to the App ID and Certificate. diff --git a/docs/FIREBASE_SETUP.md b/docs/FIREBASE_SETUP.md index ce3d661..5cf1e66 100644 --- a/docs/FIREBASE_SETUP.md +++ b/docs/FIREBASE_SETUP.md @@ -32,7 +32,7 @@ Create two distinct Firebase projects: - `playmate-app-prod` (Official Production release) ### 2. Configure Android App -1. Register Android Package: `com.example.playmate` (or custom commercial package e.g. `com.playmate.offline`). +1. Register Android Package: `com.sundramdotdev.playmate` (or custom commercial package e.g. `com.playmate.offline`). 2. Add your **SHA-1** and **SHA-256** fingerprint hashes (for debug and production keystores). 3. Download the generated `google-services.json`. 4. Place the file at: @@ -58,7 +58,7 @@ Create two distinct Firebase projects: ``` ### 3. Configure iOS App -1. Register iOS Bundle Identifier: `com.example.playmate` (or `com.playmate.offline`). +1. Register iOS Bundle Identifier: `com.sundramdotdev.playmate` (or `com.playmate.offline`). 2. Add your App Store ID and Team ID. 3. Download `GoogleService-Info.plist`. 4. Open the Xcode workspace (`ios/Runner.xcworkspace`) and drag `GoogleService-Info.plist` into `Runner/` (ensure *Copy items if needed* is checked). diff --git a/docs/SECURITY_POLICY.md b/docs/SECURITY_POLICY.md index 46afa96..9332bbb 100644 --- a/docs/SECURITY_POLICY.md +++ b/docs/SECURITY_POLICY.md @@ -19,7 +19,7 @@ We actively provide security patches for the following versions: ### 1. OS-Level Application Sandboxing PlayMate operates entirely within the native sandboxed storage boundaries enforced by Android and iOS: -- **Android:** App data is written to private internal storage (`/data/data/com.example.playmate/`), preventing access by other installed applications without root privileges. +- **Android:** App data is written to private internal storage (`/data/data/com.sundramdotdev.playmate/`), preventing access by other installed applications without root privileges. - **iOS:** Storage is restricted to the private application container (`Application Support` and `Documents` directory), shielded by iOS hardware encryption. ### 2. Local Database Security & Hive Encryption diff --git a/docs/SEO.md b/docs/SEO.md index cda5dc1..3e12f76 100644 --- a/docs/SEO.md +++ b/docs/SEO.md @@ -1,3 +1,4 @@ + # Search Engine Optimization (SEO) & Web Discovery Strategy This document establishes the strategic search engine optimization (SEO) architecture and web discovery framework for **PlayMate**. diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3683552..2619370 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -372,7 +372,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.playmate; + PRODUCT_BUNDLE_IDENTIFIER = com.sundramdotdev.playmate; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -388,7 +388,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.playmate.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.sundramdotdev.playmate.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -405,7 +405,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.playmate.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.sundramdotdev.playmate.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -420,7 +420,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.playmate.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.sundramdotdev.playmate.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -551,7 +551,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.playmate; + PRODUCT_BUNDLE_IDENTIFIER = com.sundramdotdev.playmate; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -573,7 +573,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.playmate; + PRODUCT_BUNDLE_IDENTIFIER = com.sundramdotdev.playmate; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index 7830311..e565da2 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index cf821f6..f1588e5 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 3a00565..0c7eb39 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 0ae49c7..899c234 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index a5a947c..9ca3cc8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index 6e802d8..c98f183 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index af96566..b5eb960 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 3a00565..0c7eb39 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index fba9e1e..8412ef5 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index b3d76b4..656cbad 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png index 988c161..692dbbb 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png index ec43dd9..c1e02a1 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png index 6309618..c9b7f51 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png index 2327665..b9b5ee1 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index b3d76b4..656cbad 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index 7c1c699..d596266 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index 80e4f5f..fa72415 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png index 658c7f0..f6a5598 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 5465d58..e35d230 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index c029a3f..085262b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 4c9f7b2..f3f67fd 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png index 8f4ea7f..33a2b09 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png index b24c102..0e6a348 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png index 990f3a6..d2b09b7 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png index 8f4ea7f..33a2b09 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png index b24c102..0e6a348 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png index 990f3a6..d2b09b7 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png differ diff --git a/lib/features/about/about_screen.dart b/lib/features/about/about_screen.dart index 2d9e6ff..cdf193f 100644 --- a/lib/features/about/about_screen.dart +++ b/lib/features/about/about_screen.dart @@ -8,11 +8,6 @@ import 'package_info_provider.dart'; class AboutScreen extends ConsumerWidget { const AboutScreen({super.key}); - static const String githubUrl = 'https://github.com/sundramdotdev'; - static const String linkedinUrl = 'https://linkedin.com/in/sundramdotdev'; - static const String instagramUrl = - 'https://www.instagram.com/devsundram_?stkn=dXBva2IwMzZxbHY1'; - @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); @@ -20,14 +15,14 @@ class AboutScreen extends ConsumerWidget { final String versionText = packageInfoAsync.when( data: (info) => info.version, - loading: () => '1.0.0', - error: (err, stack) => '1.0.0', + loading: () => '1.1.0', + error: (err, stack) => '1.1.0', ); final String buildText = packageInfoAsync.when( data: (info) => info.buildNumber, - loading: () => '1', - error: (err, stack) => '1', + loading: () => '2', + error: (err, stack) => '2', ); return Scaffold( @@ -41,32 +36,55 @@ class AboutScreen extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header Card + // Header Brand Card Card( child: Padding( padding: const EdgeInsets.all(AppSpacing.l), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'PlayMate', - style: theme.textTheme.headlineLarge?.copyWith( - fontWeight: FontWeight.w900, - letterSpacing: -0.5, - ), - ), - const SizedBox(height: AppSpacing.xs), - Text( - 'Everything you need for offline games.', - style: theme.textTheme.titleMedium?.copyWith( - color: theme.colorScheme.secondary, - fontWeight: FontWeight.w500, - ), + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.sports_esports, + size: 36, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: AppSpacing.m), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'PlayMate', + style: theme.textTheme.headlineLarge?.copyWith( + fontWeight: FontWeight.w900, + letterSpacing: -0.5, + ), + ), + Text( + 'Everything you need for offline games.', + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.secondary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], ), const SizedBox(height: AppSpacing.m), Text( - 'PlayMate combines common physical-game utilities into one seamless, ad-free toolkit. ' - 'Never worry about missing dice, lost scorepads, disputed coin tosses, or unorganized teams during game nights and sports matches.', + 'PlayMate combines physical tabletop and outdoor sports utilities into one seamless, ad-free toolkit. ' + 'Never worry about missing dice, lost scorepads, disputed coin tosses, or unorganized teams during game nights.', style: theme.textTheme.bodyMedium?.copyWith( height: 1.5, ), @@ -111,7 +129,7 @@ class AboutScreen extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Developed by', + 'DESIGNED & DEVELOPED BY', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.secondary, fontWeight: FontWeight.bold, @@ -120,20 +138,29 @@ class AboutScreen extends ConsumerWidget { ), const SizedBox(height: AppSpacing.xs), Text( - 'Sundramdotdev', + ExternalLinks.developerName, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: AppSpacing.m), const Divider(height: 1), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.language), + title: const Text('Official Website'), + subtitle: const Text('sundramdotdev.xyz'), + trailing: const Icon(Icons.open_in_new, size: 18), + onTap: () => ExternalLinks.openUrl(ExternalLinks.websiteUrl), + ), + const Divider(height: 1), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.code), title: const Text('GitHub'), subtitle: const Text('github.com/sundramdotdev'), trailing: const Icon(Icons.open_in_new, size: 18), - onTap: () => openExternalUrl(githubUrl), + onTap: () => ExternalLinks.openUrl(ExternalLinks.githubUrl), ), const Divider(height: 1), ListTile( @@ -142,7 +169,16 @@ class AboutScreen extends ConsumerWidget { title: const Text('LinkedIn'), subtitle: const Text('linkedin.com/in/sundramdotdev'), trailing: const Icon(Icons.open_in_new, size: 18), - onTap: () => openExternalUrl(linkedinUrl), + onTap: () => ExternalLinks.openUrl(ExternalLinks.linkedinUrl), + ), + const Divider(height: 1), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.folder_zip_outlined), + title: const Text('PlayMate Repository'), + subtitle: const Text('github.com/sundramdotdev/playmate'), + trailing: const Icon(Icons.open_in_new, size: 18), + onTap: () => ExternalLinks.openUrl(ExternalLinks.repositoryUrl), ), const Divider(height: 1), ListTile( @@ -151,7 +187,7 @@ class AboutScreen extends ConsumerWidget { title: const Text('Instagram'), subtitle: const Text('@devsundram_'), trailing: const Icon(Icons.open_in_new, size: 18), - onTap: () => openExternalUrl(instagramUrl), + onTap: () => ExternalLinks.openUrl(ExternalLinks.instagramUrl), ), ], ), @@ -165,7 +201,7 @@ class AboutScreen extends ConsumerWidget { children: [ ListTile( leading: const Icon(Icons.new_releases_outlined), - title: const Text('What\'s New'), + title: const Text('What\'s New in 1.1.0'), trailing: const Icon(Icons.chevron_right), onTap: () => context.push('/changelog'), ), diff --git a/lib/features/about/changelog_screen.dart b/lib/features/about/changelog_screen.dart index af013a9..174954b 100644 --- a/lib/features/about/changelog_screen.dart +++ b/lib/features/about/changelog_screen.dart @@ -10,15 +10,31 @@ class ChangelogScreen extends StatelessWidget { final releases = [ { - 'version': 'v1.0.0', + 'version': 'v1.1.0', 'date': 'October 2026', 'isLatest': true, + 'highlights': [ + 'High-precision monotonic Game Timer Suite with zero-drift accuracy.', + 'Full-featured Chess Clock with Fischer increment time controls (+X seconds per move).', + 'Lap & split recording in Stopwatch mode with fastest and slowest lap callouts.', + 'Custom 3D Tabletop Dice physics with realistic lighting and tumble animation.', + 'Bespoke 3D Coin Toss with dual-sided PlayMate minted faces and edge thickness.', + 'Tactile Spin Wheel with dynamic friction, ticker sound, and elimination mode.', + 'Centralized developer links & verified contact destinations.', + 'Performance optimizations, zero layout overflows across 320px–1024px screens.', + ], + }, + { + 'version': 'v1.0.0', + 'date': 'October 2026', + 'isLatest': false, 'highlights': [ 'Full suite of 8 offline gaming tools.', 'Polyhedral Dice Roller (D4, D6, D8, D10, D12, D20) with shake-to-roll physics.', '3D Coin Toss with multi-coin flipping and streak tracking.', 'Smart Team Generator with random and skill-balanced modes.', 'Universal Score Tracker with round-by-round history and undo.', + 'Gully & Club Cricket Scorer with legal delivery counting and run rate.', 'Custom Spin Wheel with angular velocity physics and elimination mode.', 'Tournament Generator for 4 to 32 team elimination brackets.', diff --git a/lib/features/about/link_launcher.dart b/lib/features/about/link_launcher.dart index 9371ebd..4f148cf 100644 --- a/lib/features/about/link_launcher.dart +++ b/lib/features/about/link_launcher.dart @@ -1,30 +1,48 @@ import 'package:flutter/foundation.dart'; import 'package:url_launcher/url_launcher.dart'; -/// Opens external URLs with graceful fallback between native OS application -/// and default mobile browser. -Future openExternalUrl(String urlString) async { - final Uri uri = Uri.parse(urlString); - try { - // Attempt opening in native app first (GitHub, LinkedIn, Instagram) - final launched = await launchUrl( - uri, - mode: LaunchMode.externalApplication, - ); - if (!launched) { - // Fallback to platform browser - await launchUrl( +/// Centralized registry for all verified developer profiles, project repositories, +/// and external destinations. +class ExternalLinks { + // Developer & Brand Profiles + static const String developerName = 'Sundramdotdev'; + static const String githubUrl = 'https://github.com/sundramdotdev'; + static const String linkedinUrl = 'https://linkedin.com/in/sundramdotdev'; + static const String websiteUrl = 'https://www.sundramdotdev.xyz'; + static const String portfolioFallbackUrl = 'https://sundramdotdev-portfolio.vercel.app'; + static const String instagramUrl = 'https://www.instagram.com/devsundram_?stkn=dXBva2IwMzZxbHY1'; + + // Project Repositories & Resources + static const String repositoryUrl = 'https://github.com/sundramdotdev/playmate'; + static const String supportEmail = 'support@playmateapp.com'; + + /// Launches a verified external URL in the default system browser or native application. + static Future openUrl(String urlString) async { + final Uri uri = Uri.parse(urlString); + try { + final launched = await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + if (launched) return true; + + return await launchUrl( uri, mode: LaunchMode.platformDefault, ); - } - } catch (e) { - debugPrint("Failed to launch URL $urlString: $e"); - try { - // Last-resort fallback - await launchUrl(uri, mode: LaunchMode.inAppBrowserView); - } catch (e2) { - debugPrint("Secondary launch failure for $urlString: $e2"); + } catch (e) { + debugPrint('Failed to open external link: $urlString ($e)'); + try { + return await launchUrl(uri, mode: LaunchMode.inAppBrowserView); + } catch (e2) { + debugPrint('Secondary fallback failed for link: $urlString ($e2)'); + return false; + } } } } + +/// Backward-compatible alias for [ExternalLinks.openUrl]. +Future openExternalUrl(String urlString) async { + await ExternalLinks.openUrl(urlString); +} diff --git a/lib/features/about/privacy_policy_screen.dart b/lib/features/about/privacy_policy_screen.dart index 1f7e409..0351668 100644 --- a/lib/features/about/privacy_policy_screen.dart +++ b/lib/features/about/privacy_policy_screen.dart @@ -79,7 +79,10 @@ class PrivacyPolicyScreen extends StatelessWidget { theme: theme, title: '8. Contact Information', body: - 'For privacy inquiries, questions, or feedback, you can contact the developer at:\nsupport@playmateapp.com\nhttps://github.com/sundramdotdev', + 'For privacy inquiries, technical questions, or feature requests, contact:\n' + 'Developer: Sundramdotdev\n' + 'Website: https://www.sundramdotdev.xyz\n' + 'GitHub: https://github.com/sundramdotdev/playmate', ), const SizedBox(height: AppSpacing.xl), ], diff --git a/lib/features/coin/coin_face.dart b/lib/features/coin/coin_face.dart new file mode 100644 index 0000000..3781441 --- /dev/null +++ b/lib/features/coin/coin_face.dart @@ -0,0 +1,293 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'coin_orientation.dart'; + +/// Reusable physical coin face renderer for HEADS and TAILS. +/// +/// Features: +/// - Concentric engraved coin rim rings +/// - Physical embossed/engraved tactile relief +/// - Integrated PlayMate branding (Signature Spark on HEADS, Geometric Monogram on TAILS) +/// - Directional metallic lighting and chamfer rim highlights +/// - Premium satin matte finish in light and dark modes +class CoinFace extends StatelessWidget { + final CoinSide side; + final double size; + final bool isDarkMode; + final double lightIntensity; + + const CoinFace({ + super.key, + required this.side, + required this.size, + this.isDarkMode = false, + this.lightIntensity = 1.0, + }); + + // Design Tokens (matching Dice system) + static const Color ivoryBody = Color(0xFFF3F0E8); + static const Color darkBody = Color(0xFF202124); + static const Color charcoalBranding = Color(0xFF17181A); + static const Color pearlBranding = Color(0xFFF5F5F2); + static const Color bronzeAccent = Color(0xFFC18A42); + + @override + Widget build(BuildContext context) { + final Color baseBody = isDarkMode ? darkBody : ivoryBody; + final Color brandColor = isDarkMode ? pearlBranding : charcoalBranding; + + // Modulate body color by directional lightIntensity + final Color litBody = Color.lerp( + isDarkMode ? const Color(0xFF141517) : const Color(0xFFDDD9CF), + baseBody, + lightIntensity.clamp(0.0, 1.0), + )!; + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: litBody, + boxShadow: [ + // Outer rim chamfer highlight + BoxShadow( + color: (isDarkMode ? Colors.white : Colors.white).withValues( + alpha: (isDarkMode ? 0.12 : 0.45) * lightIntensity, + ), + offset: const Offset(-1.5, -1.5), + blurRadius: 2.0, + ), + BoxShadow( + color: Colors.black.withValues( + alpha: (isDarkMode ? 0.65 : 0.22) * lightIntensity, + ), + offset: const Offset(1.5, 1.5), + blurRadius: 3.0, + ), + ], + ), + child: CustomPaint( + size: Size(size, size), + painter: _CoinFacePainter( + side: side, + brandColor: brandColor, + accentColor: bronzeAccent, + isDarkMode: isDarkMode, + lightIntensity: lightIntensity, + ), + ), + ); + } +} + +class _CoinFacePainter extends CustomPainter { + final CoinSide side; + final Color brandColor; + final Color accentColor; + final bool isDarkMode; + final double lightIntensity; + + const _CoinFacePainter({ + required this.side, + required this.brandColor, + required this.accentColor, + required this.isDarkMode, + required this.lightIntensity, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width / 2; + + // 1. Concentric engraved outer rim rings + _drawEngravedRims(canvas, center, radius); + + // 2. Face-specific PlayMate branding + if (side == CoinSide.heads) { + _drawHeadsBranding(canvas, center, radius); + } else { + _drawTailsBranding(canvas, center, radius); + } + } + + void _drawEngravedRims(Canvas canvas, Offset center, double radius) { + // Outer engraved trench rim + final outerRingPaint = Paint() + ..color = (isDarkMode ? Colors.black : const Color(0xFFC8C4B8)).withValues( + alpha: (isDarkMode ? 0.7 : 0.5) * lightIntensity, + ) + ..style = PaintingStyle.stroke + ..strokeWidth = radius * 0.035; + canvas.drawCircle(center, radius * 0.90, outerRingPaint); + + // Inner bevel highlight ring + final innerHighlightPaint = Paint() + ..color = (isDarkMode ? Colors.white : Colors.white).withValues( + alpha: (isDarkMode ? 0.15 : 0.6) * lightIntensity, + ) + ..style = PaintingStyle.stroke + ..strokeWidth = radius * 0.018; + canvas.drawCircle(center, radius * 0.88, innerHighlightPaint); + + // Secondary decorative concentric rim with subtle bronze accent + final decorativeRingPaint = Paint() + ..color = accentColor.withValues(alpha: 0.28 * lightIntensity) + ..style = PaintingStyle.stroke + ..strokeWidth = radius * 0.015; + canvas.drawCircle(center, radius * 0.76, decorativeRingPaint); + } + + void _drawHeadsBranding(Canvas canvas, Offset center, double radius) { + // Central signature PlayMate geometric 4-point diamond star + final starRadius = radius * 0.42; + final innerRadius = starRadius * 0.28; + + final starPath = Path(); + for (int i = 0; i < 4; i++) { + final double outerAngle = i * (math.pi / 2) - (math.pi / 2); + final double innerAngle = outerAngle + (math.pi / 4); + + final ox = center.dx + starRadius * math.cos(outerAngle); + final oy = center.dy - (radius * 0.04) + starRadius * math.sin(outerAngle); + + final ix = center.dx + innerRadius * math.cos(innerAngle); + final iy = center.dy - (radius * 0.04) + innerRadius * math.sin(innerAngle); + + if (i == 0) { + starPath.moveTo(ox, oy); + } else { + starPath.lineTo(ox, oy); + } + starPath.lineTo(ix, iy); + } + starPath.close(); + + // Subtle drop shadow for engraved depth + final shadowPaint = Paint() + ..color = Colors.black.withValues(alpha: isDarkMode ? 0.6 : 0.25) + ..style = PaintingStyle.fill; + canvas.drawPath(starPath.shift(const Offset(1.0, 1.2)), shadowPaint); + + // Main spark fill + final sparkPaint = Paint() + ..color = brandColor + ..style = PaintingStyle.fill; + canvas.drawPath(starPath, sparkPaint); + + // Bronze focal center pip + final bronzePipPaint = Paint() + ..color = accentColor + ..style = PaintingStyle.fill; + canvas.drawCircle( + Offset(center.dx, center.dy - (radius * 0.04)), + radius * 0.075, + bronzePipPaint, + ); + + // Micro text label "HEADS" + _drawMicroLabel(canvas, 'HEADS', Offset(center.dx, center.dy + radius * 0.48), radius * 0.125); + } + + void _drawTailsBranding(Canvas canvas, Offset center, double radius) { + final monogramHeight = radius * 0.60; + final monogramWidth = radius * 0.44; + final topLeft = Offset( + center.dx - monogramWidth / 2, + center.dy - (radius * 0.08) - monogramHeight / 2, + ); + + // Elegant geometric "P" monogram + final path = Path(); + final stemWidth = radius * 0.10; + final loopRadius = monogramWidth - stemWidth; + + // Vertical stem + path.addRRect(RRect.fromRectAndRadius( + Rect.fromLTWH(topLeft.dx, topLeft.dy, stemWidth, monogramHeight), + Radius.circular(stemWidth * 0.4), + )); + + // Upper semi-circle loop + final loopRect = Rect.fromLTWH( + topLeft.dx, + topLeft.dy, + monogramWidth, + monogramHeight * 0.58, + ); + final loopRRect = RRect.fromRectAndRadius(loopRect, Radius.circular(loopRadius * 0.7)); + final innerLoopRRect = RRect.fromRectAndRadius( + Rect.fromLTWH( + topLeft.dx + stemWidth, + topLeft.dy + stemWidth * 0.7, + monogramWidth - stemWidth * 1.5, + (monogramHeight * 0.58) - stemWidth * 1.4, + ), + Radius.circular(loopRadius * 0.35), + ); + + final loopPath = Path() + ..addRRect(loopRRect) + ..addRRect(innerLoopRRect) + ..fillType = PathFillType.evenOdd; + + path.addPath(loopPath, Offset.zero); + + // Shadow for tactile depth + final shadowPaint = Paint() + ..color = Colors.black.withValues(alpha: isDarkMode ? 0.6 : 0.25) + ..style = PaintingStyle.fill; + canvas.drawPath(path.shift(const Offset(1.0, 1.2)), shadowPaint); + + // Monogram body fill + final monogramPaint = Paint() + ..color = brandColor + ..style = PaintingStyle.fill; + canvas.drawPath(path, monogramPaint); + + // Micro decorative bronze diamond point inside the loop + final bronzeSparkPaint = Paint() + ..color = accentColor + ..style = PaintingStyle.fill; + canvas.drawCircle( + Offset(topLeft.dx + stemWidth + loopRadius * 0.45, topLeft.dy + monogramHeight * 0.29), + radius * 0.05, + bronzeSparkPaint, + ); + + // Micro text label "TAILS" + _drawMicroLabel(canvas, 'TAILS', Offset(center.dx, center.dy + radius * 0.48), radius * 0.125); + } + + void _drawMicroLabel(Canvas canvas, String text, Offset position, double fontSize) { + final textSpan = TextSpan( + text: text, + style: TextStyle( + color: brandColor.withValues(alpha: 0.8), + fontSize: fontSize, + fontWeight: FontWeight.w800, + letterSpacing: 2.5, + ), + ); + final textPainter = TextPainter( + text: textSpan, + textAlign: TextAlign.center, + textDirection: TextDirection.ltr, + ); + textPainter.layout(); + textPainter.paint( + canvas, + Offset(position.dx - textPainter.width / 2, position.dy - textPainter.height / 2), + ); + } + + @override + bool shouldRepaint(covariant _CoinFacePainter oldDelegate) { + return oldDelegate.side != side || + oldDelegate.brandColor != brandColor || + oldDelegate.accentColor != accentColor || + oldDelegate.isDarkMode != isDarkMode || + oldDelegate.lightIntensity != lightIntensity; + } +} diff --git a/lib/features/coin/coin_motion_config.dart b/lib/features/coin/coin_motion_config.dart new file mode 100644 index 0000000..266aa23 --- /dev/null +++ b/lib/features/coin/coin_motion_config.dart @@ -0,0 +1,72 @@ +import 'package:flutter/animation.dart'; + +/// Centralized configuration layer for PlayMate coin physics and motion. +class CoinMotionConfig { + CoinMotionConfig._(); + + // --- Animation Durations --- + /// Total standard flip sequence duration (~1050ms) + static const Duration flipDuration = Duration(milliseconds: 1050); + + /// Reduced motion quick transition duration (~220ms) + static const Duration reducedMotionDuration = Duration(milliseconds: 220); + + /// Idle subtle floating breathing cycle + static const Duration idleCycleDuration = Duration(milliseconds: 3600); + + /// Press interaction spring back duration + static const Duration pressDuration = Duration(milliseconds: 100); + + // --- Normalized Phase Intervals [0.0, 1.0] --- + /// Anticipation compression: 0ms to 100ms (0.0 to 0.095) + static const double anticipationEnd = 0.095; + + /// Launch elevation: 100ms to 240ms (0.095 to 0.228) + static const double launchEnd = 0.228; + + /// Rapid flipping: 240ms to 780ms (0.228 to 0.742) + static const double flipEnd = 0.742; + + /// Landing and primary impact: 780ms to 920ms (0.742 to 0.876) + static const double landingEnd = 0.876; + + /// Settle and micro bounce: 920ms to 1050ms (0.876 to 1.0) + static const double settleEnd = 1.0; + + // --- Spatial Scales & Offsets --- + /// Scale while pressed down by the user + static const double pressScale = 0.96; + + /// Downward vertical translation while pressed + static const double pressTranslateY = 3.5; + + /// Elevation peak during launch throw + static const double launchPeakElevationY = -42.0; + + /// Scale at peak of throw + static const double launchScale = 1.03; + + /// Scale during bounce 1 landing + static const double bounceScaleMax = 1.025; + static const double bounceScaleMin = 0.992; + + // --- Shadow Dynamics --- + /// Idle shadow scale and opacity + static const double shadowIdleScale = 1.0; + static const double shadowIdleOpacity = 0.22; + + /// Shadow at peak launch elevation (smaller & diffused) + static const double shadowLaunchScale = 0.68; + static const double shadowLaunchOpacity = 0.12; + + /// Shadow at moment of impact / landing (spreads & darkens) + static const double shadowLandingScale = 1.10; + static const double shadowLandingOpacity = 0.32; + + // --- Animation Curves --- + static const Curve anticipationCurve = Curves.easeInQuad; + static const Curve launchCurve = Curves.easeOutCubic; + static const Curve flipCurve = Curves.linear; + static const Curve landingCurve = Curves.easeOutQuad; + static const Curve settleCurve = Curves.easeOutCubic; +} diff --git a/lib/features/coin/coin_orientation.dart b/lib/features/coin/coin_orientation.dart new file mode 100644 index 0000000..72fb20f --- /dev/null +++ b/lib/features/coin/coin_orientation.dart @@ -0,0 +1,76 @@ +import 'dart:math' as math; + +/// The two logical sides of the PlayMate coin. +enum CoinSide { + heads, + tails; + + String get displayName => name.toUpperCase(); +} + +/// Centralized 3D geometry proportions and orientation mapping for the PlayMate coin. +class CoinOrientation { + CoinOrientation._(); + + /// Normalized thickness ratio relative to diameter (0.12 = 1:8.3 ratio). + static const double thicknessRatio = 0.12; + + /// Normalized rim bevel ratio (0.04 = 4% of diameter). + static const double bevelRatio = 0.04; + + /// Subtle canonical resting 3D perspective tilt. + /// Reveals the coin's physical thickness and top-edge chamfer even when idle. + static const double restingTiltX = -0.16; + static const double restingTiltY = 0.18; + static const double restingTiltZ = 0.0; + + /// Canonical base rotation around X for each side. + /// Heads: rx = 0 (outward normal pointing at camera) + /// Tails: rx = pi (outward normal pointing at camera after 180° flip) + static double getBaseAngleForSide(CoinSide side) { + switch (side) { + case CoinSide.heads: + return 0.0; + case CoinSide.tails: + return math.pi; + } + } + + /// Calculates whether the front face (Heads) is facing the camera. + /// Camera looks along -Z. cos(rx) > 0 means Heads is visible; cos(rx) < 0 means Tails is visible. + static bool isHeadsFacing(double rx) { + return math.cos(rx) >= 0.0; + } + + /// Calculates the normalized edge visibility factor in [0.0, 1.0]. + /// 0.0 = completely face-on (0° or 180°) + /// 1.0 = completely edge-on (90° or 270°) + static double getEdgeVisibility(double rx) { + return (math.sin(rx)).abs().clamp(0.0, 1.0); + } + + /// Calculates the deterministic target rotation for a flip sequence. + /// + /// [currentRx] is the current continuous rotation angle. + /// [targetSide] is the logical result that MUST be displayed at rest. + /// [fullCycles] is the number of complete 360° revolutions (e.g. 4, 5, or 6). + static double calculateTargetFlipAngle({ + required double currentRx, + required CoinSide targetSide, + required int fullCycles, + }) { + // Current base angle normalized to [0, 2pi) + final double normalizedCurrent = currentRx % (2 * math.pi); + final double targetBase = getBaseAngleForSide(targetSide); + + // Additional delta needed to reach targetBase from current position + double delta = targetBase - normalizedCurrent; + if (delta <= 0) { + delta += 2 * math.pi; + } + + // Add requested full 360° flip revolutions + final double totalAddition = (fullCycles * 2 * math.pi) + delta; + return currentRx + totalAddition; + } +} diff --git a/lib/features/coin/coin_provider.dart b/lib/features/coin/coin_provider.dart index 46bc6d3..6ffa131 100644 --- a/lib/features/coin/coin_provider.dart +++ b/lib/features/coin/coin_provider.dart @@ -1,10 +1,12 @@ import 'dart:math'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../services/firebase_service.dart'; -import '../../services/sound_service.dart'; import '../../services/storage_service.dart'; +import 'coin_motion_config.dart'; +import 'coin_orientation.dart'; -enum CoinSide { heads, tails } +// Re-export CoinSide so existing imports continue working seamlessly +export 'coin_orientation.dart' show CoinSide; class CoinState { final CoinSide? lastResult; @@ -28,63 +30,96 @@ class CoinState { tossHistory: tossHistory ?? this.tossHistory, ); } + + int get headsCount => tossHistory.where((s) => s == CoinSide.heads).length; + int get tailsCount => tossHistory.where((s) => s == CoinSide.tails).length; + int get totalTosses => tossHistory.length; } class CoinNotifier extends StateNotifier { final Random _random = Random(); - CoinNotifier() : super(CoinState(isFlipping: false, tossHistory: [])) { + CoinNotifier() + : super(CoinState( + lastResult: CoinSide.heads, + isFlipping: false, + tossHistory: [], + )) { _loadHistory(); } void _loadHistory() { + if (!StorageService.isBoxOpen(StorageService.statsBoxName)) return; final box = StorageService.getStatsBox(); final List? historyRaw = box.get('coin_history'); if (historyRaw != null) { - final history = historyRaw.map((e) => CoinSide.values.firstWhere((element) => element.name == e)).toList(); + final history = historyRaw + .map((e) => CoinSide.values.firstWhere( + (element) => element.name == e, + orElse: () => CoinSide.heads, + )) + .toList(); state = state.copyWith(tossHistory: history); } } - Future toss() async { + /// Resets the current visible coin to default Heads without wiping history. + void resetCoin() { if (state.isFlipping) return; - state = state.copyWith(isFlipping: true); + state = state.copyWith(lastResult: CoinSide.heads); + } - // Simulate flipping delay - for (int i = 0; i < 5; i++) { - await Future.delayed(const Duration(milliseconds: 100)); - SoundService.playTossSound(); - } + /// Initiates a deterministic physical coin toss sequence. + /// + /// Flow: + /// 1. Logical random result generated first + /// 2. State published with target result & isFlipping: true so 3D renderer animates to exact target + /// 3. Physical flip timeline completes + /// 4. History recorded, state marked complete (isFlipping: false) + Future toss() async { + if (state.isFlipping) return; + // 1. Generate authoritative logical result first final CoinSide result = _random.nextBool() ? CoinSide.heads : CoinSide.tails; - SoundService.triggerHaptic(); + // 2. Publish target result immediately so visual renderer computes target orientation + state = state.copyWith( + lastResult: result, + isFlipping: true, + ); + + // 3. Await duration matching physical 3D animation timeline + await Future.delayed(CoinMotionConfig.flipDuration); + + // 4. Update history upon settling final updatedHistory = List.from(state.tossHistory)..insert(0, result); if (updatedHistory.length > 50) { updatedHistory.removeLast(); } state = state.copyWith( - lastResult: result, isFlipping: false, tossHistory: updatedHistory, ); - // Save history - final box = StorageService.getStatsBox(); - await box.put('coin_history', updatedHistory.map((e) => e.name).toList()); + // Save history & statistics in storage + if (StorageService.isBoxOpen(StorageService.statsBoxName)) { + final box = StorageService.getStatsBox(); + await box.put('coin_history', updatedHistory.map((e) => e.name).toList()); - // Statistics - final totalTosses = box.get('total_coin_tosses', defaultValue: 0) as int; - await box.put('total_coin_tosses', totalTosses + 1); + final totalTosses = box.get('total_coin_tosses', defaultValue: 0) as int; + await box.put('total_coin_tosses', totalTosses + 1); + } - // Analytics + // Analytics event AnalyticsService.trackCoinToss(result.name); } void clearHistory() { state = state.copyWith(tossHistory: []); - StorageService.getStatsBox().delete('coin_history'); + if (StorageService.isBoxOpen(StorageService.statsBoxName)) { + StorageService.getStatsBox().delete('coin_history'); + } } } diff --git a/lib/features/coin/coin_screen.dart b/lib/features/coin/coin_screen.dart index 2c75f1e..f161528 100644 --- a/lib/features/coin/coin_screen.dart +++ b/lib/features/coin/coin_screen.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'dart:math'; + import '../../shared/responsive_layout.dart'; import 'coin_provider.dart'; +import 'playmate_coin.dart'; class CoinScreen extends ConsumerStatefulWidget { const CoinScreen({super.key}); @@ -11,42 +12,20 @@ class CoinScreen extends ConsumerStatefulWidget { ConsumerState createState() => _CoinScreenState(); } -class _CoinScreenState extends ConsumerState - with SingleTickerProviderStateMixin { - late AnimationController _animationController; - late Animation _animation; - - @override - void initState() { - super.initState(); - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 600), - ); - _animation = Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation( - parent: _animationController, - curve: Curves.easeInOutBack, - ), - ); - } - - void _tossCoin() { - if (_animationController.isAnimating) return; - _animationController.forward(from: 0.0); +class _CoinScreenState extends ConsumerState { + void _triggerToss() { + final coinState = ref.read(coinProvider); + if (coinState.isFlipping) return; ref.read(coinProvider.notifier).toss(); } - @override - void dispose() { - _animationController.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { final coinState = ref.watch(coinProvider); final theme = Theme.of(context); + final isDarkMode = theme.brightness == Brightness.dark; + + final currentSide = coinState.lastResult ?? CoinSide.heads; return Scaffold( appBar: AppBar( @@ -54,7 +33,16 @@ class _CoinScreenState extends ConsumerState actions: [ IconButton( icon: const Icon(Icons.refresh), - onPressed: () => ref.read(coinProvider.notifier).clearHistory(), + onPressed: coinState.isFlipping + ? null + : () => ref.read(coinProvider.notifier).resetCoin(), + tooltip: 'Reset Coin', + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: (coinState.tossHistory.isEmpty || coinState.isFlipping) + ? null + : () => ref.read(coinProvider.notifier).clearHistory(), tooltip: 'Clear History', ), ], @@ -63,141 +51,321 @@ class _CoinScreenState extends ConsumerState child: ResponsiveLayout( mobile: Column( children: [ + // Main hero area with tactile 3D coin Expanded( - child: Center( - child: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AnimatedBuilder( - animation: _animation, - builder: (context, child) { - final double value = _animation.value; - final double rotationValue = value * 4 * pi; - final double offsetValue = sin(value * pi) * -120; - return Transform( - alignment: Alignment.center, - transform: Matrix4.identity() - ..setTranslationRaw(0.0, offsetValue, 0.0) - ..rotateX(rotationValue), - child: _buildCoinWidget( - coinState.lastResult, - theme, - ), - ); - }, - ), - const SizedBox(height: AppSpacing.xxl), - Text( - coinState.isFlipping - ? 'Flipping...' - : (coinState.lastResult == null - ? 'Flip the Coin' - : coinState.lastResult!.name.toUpperCase()), - style: theme.textTheme.headlineMedium, - ), - ], - ), + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.m, + vertical: AppSpacing.s, ), - ), - ), - if (coinState.tossHistory.isNotEmpty) ...[ - const Divider(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.m), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('History', style: theme.textTheme.titleLarge), - Text( - 'Total: ${coinState.tossHistory.length}', - style: theme.textTheme.bodyMedium, + const SizedBox(height: 12), + + // Responsive Hero 3D Physical Coin (interactive press & toss) + LayoutBuilder( + builder: (context, constraints) { + final double availableWidth = constraints.maxWidth; + final double coinSize = _calculateResponsiveCoinSize(availableWidth); + + return Center( + child: PlayMateCoin( + side: currentSide, + size: coinSize, + isFlipping: coinState.isFlipping, + onTossRequested: _triggerToss, + ), + ); + }, ), + + const SizedBox(height: 16), + + // Result typography + _buildResultSection(coinState, theme), + + const SizedBox(height: 16), + + // Statistics Summary (Heads / Tails / Total) + if (coinState.tossHistory.isNotEmpty) ...[ + _buildStatsSummary(coinState, theme, isDarkMode), + const SizedBox(height: 14), + // Compact recent toss history + _buildRecentHistorySection(coinState, theme, isDarkMode), + ], ], ), ), - SizedBox( - height: 80, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.m, - vertical: AppSpacing.s, + ), + + // Bottom toss action button + _buildBottomControls(coinState, theme), + ], + ), + ), + ), + ); + } + + double _calculateResponsiveCoinSize(double availableWidth) { + if (availableWidth <= 330) { + return 130.0; + } else if (availableWidth <= 440) { + return 155.0; + } else if (availableWidth <= 600) { + return 175.0; + } else { + return 210.0; + } + } + + Widget _buildResultSection(CoinState coinState, ThemeData theme) { + if (coinState.isFlipping) { + return Column( + children: [ + Text( + 'Flipping...', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 4), + Text( + 'Tossing in the air', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.secondary, + ), + ), + ], + ); + } + + final sideName = coinState.lastResult?.displayName ?? 'HEADS'; + + return Semantics( + label: 'Coin result: $sideName', + child: Column( + children: [ + Text( + sideName, + style: theme.textTheme.headlineLarge?.copyWith( + fontSize: 38, + fontWeight: FontWeight.w900, + letterSpacing: -0.5, + height: 1.1, + ), + ), + const SizedBox(height: 4), + Text( + 'Toss Result', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.secondary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + Text( + 'Tap coin or button to toss', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.secondary.withValues(alpha: 0.8), + ), + ), + ], + ), + ); + } + + Widget _buildStatsSummary(CoinState coinState, ThemeData theme, bool isDarkMode) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: isDarkMode ? const Color(0xFF1E1E1E) : const Color(0xFFF7F7F7), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isDarkMode ? const Color(0xFF2C2C2C) : const Color(0xFFE5E5E5), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildStatPill('HEADS', '${coinState.headsCount}', theme), + Container( + height: 16, + width: 1, + margin: const EdgeInsets.symmetric(horizontal: 12), + color: theme.dividerColor, + ), + _buildStatPill('TAILS', '${coinState.tailsCount}', theme), + Container( + height: 16, + width: 1, + margin: const EdgeInsets.symmetric(horizontal: 12), + color: theme.dividerColor, + ), + _buildStatPill('TOTAL', '${coinState.totalTosses}', theme), + ], + ), + ); + } + + Widget _buildStatPill(String label, String value, ThemeData theme) { + return Column( + children: [ + Text( + value, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.secondary, + fontSize: 10, + letterSpacing: 0.5, + ), + ), + ], + ); + } + + Widget _buildRecentHistorySection( + CoinState coinState, + ThemeData theme, + bool isDarkMode, + ) { + final recent = coinState.tossHistory.take(12).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.history, + size: 15, + color: theme.colorScheme.secondary, + ), + const SizedBox(width: 6), + Text( + 'Recent Tosses', + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.secondary, + letterSpacing: 0.5, + ), + ), + ], + ), + const SizedBox(height: 8), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: recent.asMap().entries.map((entry) { + final index = entry.key; + final side = entry.value; + final isLatest = index == 0; + + return Padding( + padding: const EdgeInsets.only(right: 6.0), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: isLatest + ? (isDarkMode ? const Color(0xFF2C2C2E) : const Color(0xFFEBEBEB)) + : (isDarkMode ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5)), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isLatest + ? const Color(0xFFC18A42).withValues(alpha: 0.4) + : (isDarkMode ? const Color(0xFF2C2C2C) : const Color(0xFFE0E0E0)), + width: 1.0, ), - itemCount: coinState.tossHistory.length, - itemBuilder: (context, index) { - final item = coinState.tossHistory[index]; - return Container( - margin: const EdgeInsets.only(right: AppSpacing.s), - width: 48, - height: 48, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, decoration: BoxDecoration( - color: theme.colorScheme.surface, shape: BoxShape.circle, - border: Border.all(color: theme.colorScheme.primary), + color: side == CoinSide.heads + ? const Color(0xFFC18A42) + : (isDarkMode ? Colors.white60 : Colors.black54), ), - child: Center( - child: Text( - item == CoinSide.heads ? 'H' : 'T', - style: theme.textTheme.titleLarge, - ), + ), + const SizedBox(width: 6), + Text( + side == CoinSide.heads ? 'H' : 'T', + style: TextStyle( + fontSize: 12, + fontWeight: isLatest ? FontWeight.bold : FontWeight.w600, + color: isLatest + ? (isDarkMode ? Colors.white : const Color(0xFF1A1A1A)) + : theme.colorScheme.secondary, ), - ); - }, - ), - ), - ], - Padding( - padding: const EdgeInsets.all(AppSpacing.m), - child: SizedBox( - width: double.infinity, - height: 50, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.primary, - foregroundColor: theme.colorScheme.onPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), ), - ), - onPressed: _tossCoin, - child: const Text( - 'TOSS COIN', - style: TextStyle(fontWeight: FontWeight.bold), - ), + ], ), ), - ), - ], + ); + }).toList(), ), ), - ), + ], ); } - Widget _buildCoinWidget(CoinSide? side, ThemeData theme) { - final text = side == null ? '?' : (side == CoinSide.heads ? 'H' : 'T'); + Widget _buildBottomControls(CoinState coinState, ThemeData theme) { return Container( - width: 140, - height: 140, + padding: const EdgeInsets.all(AppSpacing.m), decoration: BoxDecoration( color: theme.colorScheme.surface, - shape: BoxShape.circle, - border: Border.all(color: theme.colorScheme.primary, width: 4), - boxShadow: [ - BoxShadow( - color: theme.colorScheme.primary.withValues(alpha: 0.15), - blurRadius: 16, - offset: const Offset(0, 8), - ), - ], + border: Border( + top: BorderSide(color: theme.dividerColor, width: 1), + ), ), - child: Center( - child: Text( - text, - style: theme.textTheme.headlineLarge?.copyWith( - fontSize: 48, - fontWeight: FontWeight.w900, + child: SafeArea( + top: false, + child: SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + disabledBackgroundColor: theme.colorScheme.primary.withValues(alpha: 0.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 0, + ), + onPressed: coinState.isFlipping ? null : _triggerToss, + child: coinState.isFlipping + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white70, + ), + ) + : const Text( + 'TOSS COIN', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + letterSpacing: 1.0, + ), + ), ), ), ), diff --git a/lib/features/coin/playmate_coin.dart b/lib/features/coin/playmate_coin.dart new file mode 100644 index 0000000..d6b0f9c --- /dev/null +++ b/lib/features/coin/playmate_coin.dart @@ -0,0 +1,525 @@ +import 'dart:io' show Platform; +import 'dart:math' as math; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../services/sound_service.dart'; +import 'coin_face.dart'; +import 'coin_motion_config.dart'; +import 'coin_orientation.dart'; + +/// Primary physical 3D PlayMate coin widget. +/// +/// Features: +/// - True programmatic 3D transformation using Matrix4 with perspective +/// - Volumetric cylindrical thickness and metallic edge rendering +/// - Distinct HEADS and TAILS faces with embossed PlayMate branding +/// - Deterministic final face orientation matching the logical result +/// - Physical anticipation, launch elevation, multi-flip tumble, and settling +/// - Responsive dynamic contact shadow +/// - Tactile press and spring-back interactions +/// - Full accessibility semantics and reduced-motion graceful fallback +class PlayMateCoin extends StatefulWidget { + final CoinSide side; + final double size; + final bool isFlipping; + final VoidCallback? onTossRequested; + final VoidCallback? onTossCompleted; + + const PlayMateCoin({ + super.key, + required this.side, + this.size = 150.0, + this.isFlipping = false, + this.onTossRequested, + this.onTossCompleted, + }); + + @override + State createState() => _PlayMateCoinState(); +} + +class _PlayMateCoinState extends State with TickerProviderStateMixin { + late AnimationController _flipController; + late AnimationController _idleController; + late AnimationController _pressController; + + double _currentRx = CoinOrientation.getBaseAngleForSide(CoinSide.heads); + double _fromRx = CoinOrientation.getBaseAngleForSide(CoinSide.heads); + double _targetRx = CoinOrientation.getBaseAngleForSide(CoinSide.heads); + + // Controlled organic variation per toss + double _secondaryDriftY = 0.0; + double _secondaryWobbleZ = 0.0; + + bool _hasTriggeredLaunchHaptic = false; + bool _hasTriggeredMidFlipSound = false; + bool _hasTriggeredLandingHaptic = false; + bool _hasTriggeredSettleFeedback = false; + + @override + void initState() { + super.initState(); + _currentRx = CoinOrientation.getBaseAngleForSide(widget.side); + _fromRx = _currentRx; + _targetRx = _currentRx; + + _flipController = AnimationController( + vsync: this, + duration: CoinMotionConfig.flipDuration, + ); + + _idleController = AnimationController( + vsync: this, + duration: CoinMotionConfig.idleCycleDuration, + ); + + // Only loop idle animation in production to prevent pumpAndSettle test timeouts + final isTesting = !kIsWeb && Platform.environment.containsKey('FLUTTER_TEST'); + if (!isTesting) { + _idleController.repeat(); + } + + _pressController = AnimationController( + vsync: this, + duration: CoinMotionConfig.pressDuration, + ); + + _flipController.addListener(_handleFlipTick); + _flipController.addStatusListener(_handleFlipStatus); + + if (widget.isFlipping) { + _startFlipSequence(); + } + } + + @override + void didUpdateWidget(covariant PlayMateCoin oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.side != oldWidget.side && !widget.isFlipping && !_flipController.isAnimating) { + setState(() { + _currentRx = CoinOrientation.getBaseAngleForSide(widget.side); + _fromRx = _currentRx; + _targetRx = _currentRx; + }); + } + + if (widget.isFlipping && !oldWidget.isFlipping && !_flipController.isAnimating) { + _startFlipSequence(); + } + } + + void _startFlipSequence() { + _fromRx = _currentRx; + + // Organic variation: randomize cycles (5 to 6 full 360° flips) and secondary axis drift + final random = math.Random(); + final int fullCycles = 5 + (random.nextInt(2)); // 5 or 6 complete flips + _secondaryDriftY = (random.nextDouble() - 0.5) * 0.16; // subtle Y drift + _secondaryWobbleZ = (random.nextDouble() - 0.5) * 0.12; // subtle Z wobble + + // Deterministically calculate target angle to land squarely on widget.side + _targetRx = CoinOrientation.calculateTargetFlipAngle( + currentRx: _fromRx, + targetSide: widget.side, + fullCycles: fullCycles, + ); + + _hasTriggeredLaunchHaptic = false; + _hasTriggeredMidFlipSound = false; + _hasTriggeredLandingHaptic = false; + _hasTriggeredSettleFeedback = false; + + _flipController.forward(from: 0.0); + } + + void _handleFlipTick() { + final t = _flipController.value; + + // Physical feedback milestones + if (t >= CoinMotionConfig.anticipationEnd && !_hasTriggeredLaunchHaptic) { + _hasTriggeredLaunchHaptic = true; + HapticFeedback.lightImpact(); + } + + if (t >= 0.45 && !_hasTriggeredMidFlipSound) { + _hasTriggeredMidFlipSound = true; + SoundService.playTossSound(); + } + + if (t >= CoinMotionConfig.flipEnd && !_hasTriggeredLandingHaptic) { + _hasTriggeredLandingHaptic = true; + SoundService.triggerHaptic(); + } + + if (t >= 0.98 && !_hasTriggeredSettleFeedback) { + _hasTriggeredSettleFeedback = true; + HapticFeedback.selectionClick(); + } + + // Interpolate rotation progress + final double rotT = _calculateRotationProgress(t); + setState(() { + _currentRx = _fromRx + (_targetRx - _fromRx) * rotT; + }); + } + + double _calculateRotationProgress(double t) { + if (t < CoinMotionConfig.anticipationEnd) { + // Wind-up compression + final subT = t / CoinMotionConfig.anticipationEnd; + return -0.01 * CoinMotionConfig.anticipationCurve.transform(subT); + } else { + // Fast acceleration into smooth natural deceleration + final subT = (t - CoinMotionConfig.anticipationEnd) / + (1.0 - CoinMotionConfig.anticipationEnd); + return Curves.easeOutCubic.transform(subT.clamp(0.0, 1.0)); + } + } + + void _handleFlipStatus(AnimationStatus status) { + if (status == AnimationStatus.completed) { + // Normalize resting rotation modulo 2pi for mathematical cleanliness + final baseTarget = CoinOrientation.getBaseAngleForSide(widget.side); + setState(() { + _currentRx = baseTarget; + _fromRx = baseTarget; + _targetRx = baseTarget; + }); + widget.onTossCompleted?.call(); + } + } + + @override + void dispose() { + _flipController.dispose(); + _idleController.dispose(); + _pressController.dispose(); + super.dispose(); + } + + void _onTapDown(TapDownDetails details) { + if (widget.isFlipping || _flipController.isAnimating) return; + _pressController.forward(); + HapticFeedback.selectionClick(); + } + + void _onTapUp(TapUpDetails details) { + if (widget.isFlipping || _flipController.isAnimating) return; + _pressController.reverse(); + widget.onTossRequested?.call(); + } + + void _onTapCancel() { + if (widget.isFlipping || _flipController.isAnimating) return; + _pressController.reverse(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDarkMode = theme.brightness == Brightness.dark; + final reducedMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false; + + return Semantics( + label: widget.isFlipping + ? 'Coin is flipping' + : 'Coin showing ${widget.side.displayName}. Double tap to toss', + button: true, + enabled: !widget.isFlipping, + child: GestureDetector( + onTapDown: _onTapDown, + onTapUp: _onTapUp, + onTapCancel: _onTapCancel, + child: AnimatedBuilder( + animation: Listenable.merge([_flipController, _idleController, _pressController]), + builder: (context, _) { + final double flipT = _flipController.value; + final double pressT = _pressController.value; + + // 1. Calculate physical scale + double scale = 1.0 - (pressT * (1.0 - CoinMotionConfig.pressScale)); + if (!reducedMotion && _flipController.isAnimating) { + if (flipT < CoinMotionConfig.anticipationEnd) { + final subT = flipT / CoinMotionConfig.anticipationEnd; + scale = 1.0 - (subT * (1.0 - CoinMotionConfig.pressScale)); + } else if (flipT < CoinMotionConfig.launchEnd) { + final subT = (flipT - CoinMotionConfig.anticipationEnd) / + (CoinMotionConfig.launchEnd - CoinMotionConfig.anticipationEnd); + scale = CoinMotionConfig.pressScale + + subT * (CoinMotionConfig.launchScale - CoinMotionConfig.pressScale); + } else if (flipT < CoinMotionConfig.flipEnd) { + final subT = (flipT - CoinMotionConfig.launchEnd) / + (CoinMotionConfig.flipEnd - CoinMotionConfig.launchEnd); + scale = CoinMotionConfig.launchScale - subT * (CoinMotionConfig.launchScale - 1.0); + } else if (flipT < CoinMotionConfig.landingEnd) { + final subT = (flipT - CoinMotionConfig.flipEnd) / + (CoinMotionConfig.landingEnd - CoinMotionConfig.flipEnd); + final bouncePhase = math.sin(subT * math.pi * 2); + scale = 1.0 + (bouncePhase * 0.025); + } else { + final subT = (flipT - CoinMotionConfig.landingEnd) / + (CoinMotionConfig.settleEnd - CoinMotionConfig.landingEnd); + final settlePhase = math.sin(subT * math.pi * 2); + scale = 1.0 + (settlePhase * 0.008); + } + } + + // 2. Calculate physical elevation translateY + double translateY = pressT * CoinMotionConfig.pressTranslateY; + if (!reducedMotion && _flipController.isAnimating) { + if (flipT < CoinMotionConfig.anticipationEnd) { + translateY = CoinMotionConfig.pressTranslateY * (flipT / CoinMotionConfig.anticipationEnd); + } else if (flipT < CoinMotionConfig.launchEnd) { + final subT = (flipT - CoinMotionConfig.anticipationEnd) / + (CoinMotionConfig.launchEnd - CoinMotionConfig.anticipationEnd); + translateY = CoinMotionConfig.pressTranslateY + + Curves.easeOutQuad.transform(subT) * + (CoinMotionConfig.launchPeakElevationY - CoinMotionConfig.pressTranslateY); + } else if (flipT < CoinMotionConfig.flipEnd) { + final subT = (flipT - CoinMotionConfig.launchEnd) / + (CoinMotionConfig.flipEnd - CoinMotionConfig.launchEnd); + translateY = CoinMotionConfig.launchPeakElevationY * + (1.0 - Curves.easeInQuad.transform(subT)); + } else if (flipT < CoinMotionConfig.landingEnd) { + final subT = (flipT - CoinMotionConfig.flipEnd) / + (CoinMotionConfig.landingEnd - CoinMotionConfig.flipEnd); + translateY = -4.0 * math.sin(subT * math.pi); + } else { + translateY = 0.0; + } + } else if (!reducedMotion && !_flipController.isAnimating && pressT == 0) { + // Idle subtle breathing drift (~1.5px) + final idlePhase = math.sin(_idleController.value * math.pi * 2); + translateY = -1.5 * idlePhase; + } + + // 3. Dynamic contact shadow + double shadowScale = CoinMotionConfig.shadowIdleScale; + double shadowOpacity = isDarkMode + ? CoinMotionConfig.shadowIdleOpacity * 1.5 + : CoinMotionConfig.shadowIdleOpacity; + + if (!reducedMotion && _flipController.isAnimating) { + if (flipT < CoinMotionConfig.launchEnd) { + final subT = (flipT / CoinMotionConfig.launchEnd).clamp(0.0, 1.0); + shadowScale = CoinMotionConfig.shadowIdleScale + + subT * (CoinMotionConfig.shadowLaunchScale - CoinMotionConfig.shadowIdleScale); + shadowOpacity = shadowOpacity + + subT * (CoinMotionConfig.shadowLaunchOpacity - shadowOpacity); + } else if (flipT < CoinMotionConfig.flipEnd) { + final subT = ((flipT - CoinMotionConfig.launchEnd) / + (CoinMotionConfig.flipEnd - CoinMotionConfig.launchEnd)) + .clamp(0.0, 1.0); + shadowScale = CoinMotionConfig.shadowLaunchScale + + subT * (CoinMotionConfig.shadowLandingScale - CoinMotionConfig.shadowLaunchScale); + shadowOpacity = CoinMotionConfig.shadowLaunchOpacity + + subT * (CoinMotionConfig.shadowLandingOpacity - CoinMotionConfig.shadowLaunchOpacity); + } else { + final subT = ((flipT - CoinMotionConfig.flipEnd) / + (1.0 - CoinMotionConfig.flipEnd)) + .clamp(0.0, 1.0); + shadowScale = CoinMotionConfig.shadowLandingScale + + subT * (CoinMotionConfig.shadowIdleScale - CoinMotionConfig.shadowLandingScale); + shadowOpacity = CoinMotionConfig.shadowLandingOpacity + + subT * (CoinMotionConfig.shadowIdleOpacity - CoinMotionConfig.shadowLandingOpacity); + } + } + + final double coinSize = widget.size; + final double thickness = coinSize * CoinOrientation.thicknessRatio; + + // Secondary rotations during tumbling + final double secondaryY = _flipController.isAnimating + ? CoinOrientation.restingTiltY + math.sin(flipT * math.pi) * _secondaryDriftY + : CoinOrientation.restingTiltY; + final double secondaryZ = _flipController.isAnimating + ? CoinOrientation.restingTiltZ + math.sin(flipT * math.pi * 2) * _secondaryWobbleZ + : CoinOrientation.restingTiltZ; + + return RepaintBoundary( + child: SizedBox( + width: coinSize * 1.25, + height: coinSize * 1.45, + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + // Dynamic ground shadow + Positioned( + bottom: coinSize * 0.10, + child: Transform.scale( + scale: shadowScale, + child: Opacity( + opacity: shadowOpacity.clamp(0.0, 1.0), + child: Container( + width: coinSize * 0.85, + height: coinSize * 0.24, + decoration: BoxDecoration( + borderRadius: BorderRadius.all( + Radius.elliptical(coinSize * 0.42, coinSize * 0.12)), + gradient: RadialGradient( + colors: [ + Colors.black.withValues(alpha: 0.65), + Colors.black.withValues(alpha: 0.22), + Colors.transparent, + ], + stops: const [0.0, 0.45, 1.0], + ), + ), + ), + ), + ), + ), + + // Physical 3D Coin with cylindrical thickness + Transform.translate( + offset: Offset(0, translateY), + child: Transform.scale( + scale: scale, + child: _build3DCoin( + coinSize: coinSize, + thickness: thickness, + rx: _currentRx + CoinOrientation.restingTiltX, + ry: secondaryY, + rz: secondaryZ, + isDarkMode: isDarkMode, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } + + /// Builds volumetric 3D coin with front face, back face, and cylindrical edge + Widget _build3DCoin({ + required double coinSize, + required double thickness, + required double rx, + required double ry, + required double rz, + required bool isDarkMode, + }) { + final double halfThickness = thickness / 2; + + // Normal Z component of Front Face (Heads) in rotated camera space + // Negative = facing camera, Positive = facing away + final double cosRx = math.cos(rx); + final bool isHeadsFacing = cosRx >= 0.0; + + // Directional lighting based on orientation relative to light (-0.35, -0.65, -0.67) + final double lightFront = (0.78 + (0.22 * math.max(0.0, cosRx))).clamp(0.0, 1.0); + final double lightBack = (0.78 + (0.22 * math.max(0.0, -cosRx))).clamp(0.0, 1.0); + + // 3D rotation transform for the coin container + final coinTransform = Matrix4.identity() + ..setEntry(3, 2, 0.0016) // Perspective + ..rotateX(rx) + ..rotateY(ry) + ..rotateZ(rz); + + // Number of cylindrical edge slices + const int edgeSliceCount = 8; + final edgeColor = isDarkMode ? const Color(0xFF1E1F23) : const Color(0xFF2B2C31); + + // Edge slices ordered along Z from -halfThickness to +halfThickness + final List children = []; + + // Painter's order: + // If Heads is facing camera (cosRx >= 0): + // 1. Tails (Back) disc at +halfThickness + // 2. Edge slices + // 3. Heads (Front) disc at -halfThickness + // If Tails is facing camera (cosRx < 0): + // 1. Heads (Front) disc at -halfThickness + // 2. Edge slices + // 3. Tails (Back) disc at +halfThickness + + final Widget tailsFaceWidget = Transform( + transform: Matrix4.identity() + ..rotateY(math.pi) + ..multiply(Matrix4.translationValues(0.0, 0.0, -halfThickness)), + alignment: Alignment.center, + child: CoinFace( + side: CoinSide.tails, + size: coinSize, + isDarkMode: isDarkMode, + lightIntensity: lightBack, + ), + ); + + final Widget headsFaceWidget = Transform( + transform: Matrix4.translationValues(0.0, 0.0, -halfThickness), + alignment: Alignment.center, + child: CoinFace( + side: CoinSide.heads, + size: coinSize, + isDarkMode: isDarkMode, + lightIntensity: lightFront, + ), + ); + + // Add back face first if Heads is facing, or front face first if Tails is facing + if (isHeadsFacing) { + children.add(tailsFaceWidget); + } else { + children.add(headsFaceWidget); + } + + // Cylindrical edge slices along Z creating continuous solid cylindrical thickness + for (int i = 0; i < edgeSliceCount; i++) { + final double zSlice = -halfThickness + + (thickness * (i + 0.5) / edgeSliceCount); + + children.add( + Transform( + transform: Matrix4.translationValues(0.0, 0.0, zSlice), + alignment: Alignment.center, + child: Container( + width: coinSize, + height: coinSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: edgeColor, + border: Border.all( + color: isDarkMode + ? Colors.white.withValues(alpha: 0.06) + : Colors.white.withValues(alpha: 0.18), + width: 0.8, + ), + ), + ), + ), + ); + } + + // Add top facing face last in painter's order + if (isHeadsFacing) { + children.add(headsFaceWidget); + } else { + children.add(tailsFaceWidget); + } + + return Transform( + transform: coinTransform, + alignment: Alignment.center, + child: SizedBox( + width: coinSize, + height: coinSize, + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: children, + ), + ), + ); + } +} diff --git a/lib/features/dice/dice_face.dart b/lib/features/dice/dice_face.dart new file mode 100644 index 0000000..3a16eb5 --- /dev/null +++ b/lib/features/dice/dice_face.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'dice_pips.dart'; + +/// Extensible model for physical dice styles and finishes. +class DiceStyle { + final Color bodyColor; + final Color pipColor; + final Color accentColor; + final Color highlightColor; + final Color shadowEdgeColor; + + const DiceStyle({ + required this.bodyColor, + required this.pipColor, + required this.accentColor, + required this.highlightColor, + required this.shadowEdgeColor, + }); + + /// Default PlayMate warm ivory finish. + static const DiceStyle warmIvory = DiceStyle( + bodyColor: Color(0xFFF3F0E8), + pipColor: Color(0xFF17181A), + accentColor: Color(0xFFC18A42), + highlightColor: Color(0x66FFFFFF), + shadowEdgeColor: Color(0x24000000), + ); + + /// Sleek obsidian graphite dark mode finish. + static const DiceStyle obsidianDark = DiceStyle( + bodyColor: Color(0xFF202124), + pipColor: Color(0xFFF5F5F2), + accentColor: Color(0xFFC18A42), + highlightColor: Color(0x26FFFFFF), + shadowEdgeColor: Color(0x80000000), + ); + + /// Selects the appropriate style based on current brightness. + static DiceStyle fromBrightness(Brightness brightness) { + return brightness == Brightness.dark ? obsidianDark : warmIvory; + } +} + +/// A single rendered physical face of the PlayMate die. +class DiceFace extends StatelessWidget { + final int value; + final double size; + final DiceStyle style; + final double lightIntensity; + final bool isDarkMode; + + const DiceFace({ + super.key, + required this.value, + required this.size, + required this.style, + this.lightIntensity = 1.0, + this.isDarkMode = false, + }); + + @override + Widget build(BuildContext context) { + final double radius = size * 0.20; + + // Modulate body color by lightIntensity (0.78 to 1.0) + final Color litBody = Color.lerp( + isDarkMode ? const Color(0xFF121315) : const Color(0xFFDDD9CF), + style.bodyColor, + lightIntensity, + )!; + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: litBody, + borderRadius: BorderRadius.circular(radius), + border: Border.all( + color: isDarkMode + ? style.highlightColor.withValues(alpha: 0.15 * lightIntensity) + : style.shadowEdgeColor.withValues(alpha: 0.12 * lightIntensity), + width: 1.0, + ), + boxShadow: [ + // Subtle inner face bevel and chamfer rim + BoxShadow( + color: style.highlightColor.withValues(alpha: 0.35 * lightIntensity), + offset: const Offset(-1.2, -1.2), + blurRadius: 1.5, + spreadRadius: 0.0, + ), + BoxShadow( + color: style.shadowEdgeColor.withValues(alpha: 0.25 * lightIntensity), + offset: const Offset(1.5, 1.5), + blurRadius: 2.0, + spreadRadius: 0.0, + ), + ], + ), + child: Center( + child: DicePips( + value: value, + faceSize: size, + pipColor: style.pipColor, + accentColor: style.accentColor, + isDarkMode: isDarkMode, + ), + ), + ); + } +} diff --git a/lib/features/dice/dice_motion_config.dart b/lib/features/dice/dice_motion_config.dart new file mode 100644 index 0000000..e43abee --- /dev/null +++ b/lib/features/dice/dice_motion_config.dart @@ -0,0 +1,76 @@ +import 'package:flutter/animation.dart'; + +/// Centralized configuration layer for PlayMate dice physics and motion. +class DiceMotionConfig { + DiceMotionConfig._(); + + // --- Animation Durations --- + /// Total standard roll sequence duration (~1050ms) + static const Duration rollDuration = Duration(milliseconds: 1050); + + /// Reduced motion quick transition duration (~220ms) + static const Duration reducedMotionDuration = Duration(milliseconds: 220); + + /// Idle subtle floating breathing cycle + static const Duration idleCycleDuration = Duration(milliseconds: 3600); + + /// Press interaction spring back duration + static const Duration pressDuration = Duration(milliseconds: 120); + + // --- Normalized Phase Intervals [0.0, 1.0] --- + /// Anticipation compression: 0ms to 120ms (0.0 to 0.114) + static const double anticipationEnd = 0.114; + + /// Launch elevation: 120ms to 280ms (0.114 to 0.266) + static const double launchEnd = 0.266; + + /// Tumbling spin: 280ms to 800ms (0.266 to 0.762) + static const double tumbleEnd = 0.762; + + /// Landing and primary bounce: 800ms to 950ms (0.762 to 0.905) + static const double landingEnd = 0.905; + + /// Settle and micro confirmation: 950ms to 1050ms (0.905 to 1.0) + static const double settleEnd = 1.0; + + // --- Spatial Scales & Offsets --- + /// Scale while pressed down by the user + static const double pressScale = 0.96; + + /// Downward vertical translation while pressed + static const double pressTranslateY = 4.0; + + /// Elevation peak during launch + static const double launchPeakElevationY = -34.0; + + /// Scale at peak of throw + static const double launchScale = 1.04; + + /// Scale during bounce 1 landing + static const double bounce1ScaleMax = 1.04; + static const double bounce1ScaleMin = 0.985; + + /// Scale during bounce 2 settle + static const double bounce2ScaleMax = 1.015; + static const double bounce2ScaleMin = 0.995; + + // --- Shadow Dynamics --- + /// Idle shadow scale and opacity + static const double shadowIdleScale = 1.0; + static const double shadowIdleOpacity = 0.26; + + /// Shadow at peak launch elevation (smaller & diffused) + static const double shadowLaunchScale = 0.72; + static const double shadowLaunchOpacity = 0.14; + + /// Shadow at moment of impact / landing (spreads & darkens) + static const double shadowLandingScale = 1.08; + static const double shadowLandingOpacity = 0.35; + + // --- Animation Curves --- + static const Curve anticipationCurve = Curves.easeInQuad; + static const Curve launchCurve = Curves.easeOutCubic; + static const Curve tumbleCurve = Curves.linear; + static const Curve landingCurve = Curves.bounceOut; + static const Curve settleCurve = Curves.easeOutCubic; +} diff --git a/lib/features/dice/dice_orientation.dart b/lib/features/dice/dice_orientation.dart new file mode 100644 index 0000000..8a49c6e --- /dev/null +++ b/lib/features/dice/dice_orientation.dart @@ -0,0 +1,243 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; + +/// Lightweight 3D vector for dice physics, normals, and Euler angles. +class DiceVec3 { + final double x; + final double y; + final double z; + + const DiceVec3(this.x, this.y, this.z); + + DiceVec3 operator +(DiceVec3 other) => + DiceVec3(x + other.x, y + other.y, z + other.z); + + DiceVec3 operator -(DiceVec3 other) => + DiceVec3(x - other.x, y - other.y, z - other.z); + + DiceVec3 operator *(double scalar) => + DiceVec3(x * scalar, y * scalar, z * scalar); + + double dot(DiceVec3 other) => x * other.x + y * other.y + z * other.z; + + double get length => math.sqrt(x * x + y * y + z * z); + + DiceVec3 normalized() { + final len = length; + if (len == 0) return const DiceVec3(0, 0, 0); + return DiceVec3(x / len, y / len, z / len); + } + + @override + String toString() => 'DiceVec3($x, $y, $z)'; +} + +/// Definition of a single physical face of the 6-sided die. +class DiceFaceDef { + final int value; + final DiceVec3 localNormal; + final DiceVec3 localCenter; + + const DiceFaceDef({ + required this.value, + required this.localNormal, + required this.localCenter, + }); +} + +/// Metadata for rendering a visible face with depth sorting and lighting. +class VisibleDiceFace { + final int value; + final double depthZ; + final double lightIntensity; + final Matrix4 transform; + + const VisibleDiceFace({ + required this.value, + required this.depthZ, + required this.lightIntensity, + required this.transform, + }); +} + +/// Central source of truth for 3D cube face orientation and coordinate mapping. +class DiceFaceOrientation { + DiceFaceOrientation._(); + + /// Canonical resting tilt giving a pleasant 3D isometric perspective. + /// Reveals the front face prominently while showing the top and right chamfers. + static const DiceVec3 restingTilt = DiceVec3(-0.24, 0.28, 0.0); + + /// Normalized ambient light vector coming from the top-left-front. + static final DiceVec3 lightDirection = + const DiceVec3(-0.35, -0.65, -0.67).normalized(); + + /// Canonical 6-face definitions on a unit cube with half-extent 1.0. + /// Opposite faces sum to 7: + /// 1 (Front: z = -1) <-> 6 (Back: z = +1) + /// 2 (Top: y = -1) <-> 5 (Bottom: y = +1) + /// 3 (Right: x = +1) <-> 4 (Left: x = -1) + static const List canonicalFaces = [ + DiceFaceDef( + value: 1, + localNormal: DiceVec3(0, 0, -1), + localCenter: DiceVec3(0, 0, -1), + ), + DiceFaceDef( + value: 6, + localNormal: DiceVec3(0, 0, 1), + localCenter: DiceVec3(0, 0, 1), + ), + DiceFaceDef( + value: 2, + localNormal: DiceVec3(0, -1, 0), + localCenter: DiceVec3(0, -1, 0), + ), + DiceFaceDef( + value: 5, + localNormal: DiceVec3(0, 1, 0), + localCenter: DiceVec3(0, 1, 0), + ), + DiceFaceDef( + value: 3, + localNormal: DiceVec3(1, 0, 0), + localCenter: DiceVec3(1, 0, 0), + ), + DiceFaceDef( + value: 4, + localNormal: DiceVec3(-1, 0, 0), + localCenter: DiceVec3(-1, 0, 0), + ), + ]; + + /// Base Euler angles (rx, ry, rz) to bring face [value] squarely to front (normal = 0, 0, -1). + static DiceVec3 getBaseEulerForValue(int value) { + switch (value) { + case 1: // Front + return const DiceVec3(0, 0, 0); + case 2: // Top rotates down around X + return const DiceVec3(math.pi / 2, 0, 0); + case 3: // Right rotates towards front around Y + return const DiceVec3(0, math.pi / 2, 0); + case 4: // Left rotates towards front around Y + return const DiceVec3(0, -math.pi / 2, 0); + case 5: // Bottom rotates up around X + return const DiceVec3(-math.pi / 2, 0, 0); + case 6: // Back rotates to front around Y + return const DiceVec3(0, math.pi, 0); + default: + return const DiceVec3(0, 0, 0); + } + } + + /// Target Euler rotation for resting die displaying [value], including 3D perspective tilt. + static DiceVec3 getRestingEulerForValue(int value) { + final base = getBaseEulerForValue(value); + return DiceVec3( + base.x + restingTilt.x, + base.y + restingTilt.y, + base.z + restingTilt.z, + ); + } + + /// Precomputes the local transformation matrix for a face of size [faceSize]. + static Matrix4 getLocalFaceMatrix(int value, double halfExtent) { + final m = Matrix4.identity(); + final translation = Matrix4.translationValues(0.0, 0.0, -halfExtent); + switch (value) { + case 1: // Front: translated along -Z + m.multiply(translation); + break; + case 6: // Back: rotate around Y by pi, translate along -Z + m.rotateY(math.pi); + m.multiply(translation); + break; + case 2: // Top: rotate around X by -pi/2, translate along -Z + m.rotateX(-math.pi / 2); + m.multiply(translation); + break; + case 5: // Bottom: rotate around X by pi/2, translate along -Z + m.rotateX(math.pi / 2); + m.multiply(translation); + break; + case 3: // Right: rotate around Y by pi/2, translate along -Z + m.rotateY(math.pi / 2); + m.multiply(translation); + break; + case 4: // Left: rotate around Y by -pi/2, translate along -Z + m.rotateY(-math.pi / 2); + m.multiply(translation); + break; + } + return m; + } + + /// Calculates visible faces, their painter's algorithm depth order, and directional lighting. + static List computeVisibleFaces({ + required DiceVec3 cubeRotation, + required double halfExtent, + double perspective = 0.0016, + }) { + // Rotation matrix for cube + final rotMatrix = Matrix4.identity() + ..rotateX(cubeRotation.x) + ..rotateY(cubeRotation.y) + ..rotateZ(cubeRotation.z); + + final s = rotMatrix.storage; + final List visible = []; + + for (final face in canonicalFaces) { + // Rotate local normal by rotMatrix 3x3 + final nx = face.localNormal.x; + final ny = face.localNormal.y; + final nz = face.localNormal.z; + + final worldNormalZ = s[2] * nx + s[6] * ny + s[10] * nz; + + // Back-face culling: camera looks down -Z. Visible if normal points towards camera (z <= 0) + if (worldNormalZ > 0.0) { + continue; + } + + final worldNormalX = s[0] * nx + s[4] * ny + s[8] * nz; + final worldNormalY = s[1] * nx + s[5] * ny + s[9] * nz; + final worldNormal = DiceVec3(worldNormalX, worldNormalY, worldNormalZ); + + // World center Z for depth sorting + final cx = face.localCenter.x * halfExtent; + final cy = face.localCenter.y * halfExtent; + final cz = face.localCenter.z * halfExtent; + final worldCenterZ = s[2] * cx + s[6] * cy + s[10] * cz; + + // Diffuse lighting intensity: how directly it faces light + final dot = -worldNormal.dot(lightDirection); + final clampedDot = dot.clamp(0.0, 1.0); + final lightIntensity = 0.78 + (0.22 * clampedDot); + + // Full face transformation matrix: + // Perspective * Rotation * Local Face Offset + final localMatrix = getLocalFaceMatrix(face.value, halfExtent); + final fullMatrix = Matrix4.identity(); + if (perspective > 0) { + fullMatrix.setEntry(3, 2, perspective); + } + fullMatrix.multiply(rotMatrix); + fullMatrix.multiply(localMatrix); + + visible.add(VisibleDiceFace( + value: face.value, + depthZ: worldCenterZ, + lightIntensity: lightIntensity, + transform: fullMatrix, + )); + } + + // Sort by depth for painter's algorithm: + // Larger depthZ is further back in the screen (drawn first), + // Smaller/more negative depthZ is closer to camera (drawn last on top). + visible.sort((a, b) => b.depthZ.compareTo(a.depthZ)); + + return visible; + } +} diff --git a/lib/features/dice/dice_pips.dart b/lib/features/dice/dice_pips.dart new file mode 100644 index 0000000..e8995fd --- /dev/null +++ b/lib/features/dice/dice_pips.dart @@ -0,0 +1,194 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; + +/// Reusable pip renderer using normalized coordinates [-1.0, 1.0]. +/// +/// Pips are rendered with a tactile recessed depth (inner rim shading and soft +/// radial falloff) rather than flat painted circles. +/// +/// Face 1 integrates a subtle PlayMate geometric brand mark (warm bronze spark) +/// in the center of the recessed pip. +class DicePips extends StatelessWidget { + final int value; + final double faceSize; + final Color pipColor; + final Color accentColor; + final bool isDarkMode; + + const DicePips({ + super.key, + required this.value, + required this.faceSize, + required this.pipColor, + this.accentColor = const Color(0xFFC18A42), // PlayMate signature bronze + this.isDarkMode = false, + }); + + /// Canonical normalized pip coordinates mapped onto [-1.0, 1.0]. + static const Map> normalizedPipOffsets = { + 1: [Offset(0.0, 0.0)], + 2: [Offset(-0.54, -0.54), Offset(0.54, 0.54)], + 3: [Offset(-0.54, -0.54), Offset(0.0, 0.0), Offset(0.54, 0.54)], + 4: [ + Offset(-0.54, -0.54), + Offset(0.54, -0.54), + Offset(-0.54, 0.54), + Offset(0.54, 0.54), + ], + 5: [ + Offset(-0.54, -0.54), + Offset(0.54, -0.54), + Offset(0.0, 0.0), + Offset(-0.54, 0.54), + Offset(0.54, 0.54), + ], + 6: [ + Offset(-0.54, -0.58), + Offset(0.54, -0.58), + Offset(-0.54, 0.0), + Offset(0.54, 0.0), + Offset(-0.54, 0.58), + Offset(0.54, 0.58), + ], + }; + + @override + Widget build(BuildContext context) { + return CustomPaint( + size: Size(faceSize, faceSize), + painter: _DicePipsPainter( + value: value, + pipColor: pipColor, + accentColor: accentColor, + isDarkMode: isDarkMode, + ), + ); + } +} + +class _DicePipsPainter extends CustomPainter { + final int value; + final Color pipColor; + final Color accentColor; + final bool isDarkMode; + + const _DicePipsPainter({ + required this.value, + required this.pipColor, + required this.accentColor, + required this.isDarkMode, + }); + + @override + void paint(Canvas canvas, Size size) { + if (value < 1 || value > 6) return; + + final offsets = DicePips.normalizedPipOffsets[value] ?? []; + final center = Offset(size.width / 2, size.height / 2); + // Usable half dimension inside face padding (32% margin) + final double halfSpan = size.width * 0.28; + // Pip radius is approximately 9% of face size + final double pipRadius = size.width * 0.092; + + for (int i = 0; i < offsets.length; i++) { + final norm = offsets[i]; + final pipCenter = Offset( + center.dx + norm.dx * halfSpan, + center.dy + norm.dy * halfSpan, + ); + + final isCenterOnePip = (value == 1 && norm == Offset.zero); + _drawRecessedPip(canvas, pipCenter, pipRadius, isCenterOnePip); + } + } + + void _drawRecessedPip(Canvas canvas, Offset center, double radius, bool isOnePip) { + // 1. Recessed inner shadow (top-left rim) + final innerShadowPaint = Paint() + ..color = isDarkMode + ? Colors.black.withValues(alpha: 0.6) + : Colors.black.withValues(alpha: 0.25) + ..style = PaintingStyle.fill; + canvas.drawCircle(center + const Offset(0.4, 0.6), radius + 0.5, innerShadowPaint); + + // 2. Subtle light reflection on bottom-right rim + final rimHighlightPaint = Paint() + ..color = isDarkMode + ? Colors.white.withValues(alpha: 0.12) + : Colors.white.withValues(alpha: 0.5) + ..style = PaintingStyle.stroke + ..strokeWidth = 0.8; + canvas.drawCircle(center, radius, rimHighlightPaint); + + // 3. Pip core gradient fill (darker at top-left, slightly softer at bottom-right) + final pipGradient = RadialGradient( + center: const Alignment(-0.25, -0.25), + radius: 0.9, + colors: isDarkMode + ? [ + pipColor, + pipColor.withValues(alpha: 0.88), + ] + : [ + pipColor.withValues(alpha: 0.92), + pipColor, + ], + ); + + final pipPaint = Paint() + ..shader = pipGradient.createShader( + Rect.fromCircle(center: center, radius: radius), + ); + canvas.drawCircle(center, radius, pipPaint); + + // 4. Subtle PlayMate signature geometric mark for Face 1 + if (isOnePip) { + _drawPlayMateBrandMark(canvas, center, radius); + } + } + + void _drawPlayMateBrandMark(Canvas canvas, Offset center, double pipRadius) { + // Elegant geometric 4-point diamond star / play spark in warm bronze + final double starRadius = pipRadius * 0.65; + final double innerRadius = starRadius * 0.26; + + final path = Path(); + for (int i = 0; i < 4; i++) { + final double outerAngle = i * (math.pi / 2) - (math.pi / 2); + final double innerAngle = outerAngle + (math.pi / 4); + + final outerX = center.dx + starRadius * math.cos(outerAngle); + final outerY = center.dy + starRadius * math.sin(outerAngle); + + final innerX = center.dx + innerRadius * math.cos(innerAngle); + final innerY = center.dy + innerRadius * math.sin(innerAngle); + + if (i == 0) { + path.moveTo(outerX, outerY); + } else { + path.lineTo(outerX, outerY); + } + path.lineTo(innerX, innerY); + } + path.close(); + + final markPaint = Paint() + ..color = accentColor + ..style = PaintingStyle.fill; + canvas.drawPath(path, markPaint); + + // Micro center point + final dotPaint = Paint() + ..color = isDarkMode ? const Color(0xFF17181A) : const Color(0xFFF3F0E8) + ..style = PaintingStyle.fill; + canvas.drawCircle(center, pipRadius * 0.14, dotPaint); + } + + @override + bool shouldRepaint(covariant _DicePipsPainter oldDelegate) { + return oldDelegate.value != value || + oldDelegate.pipColor != pipColor || + oldDelegate.accentColor != accentColor || + oldDelegate.isDarkMode != isDarkMode; + } +} diff --git a/lib/features/dice/dice_provider.dart b/lib/features/dice/dice_provider.dart index 2ca84a9..4cabb4b 100644 --- a/lib/features/dice/dice_provider.dart +++ b/lib/features/dice/dice_provider.dart @@ -1,20 +1,21 @@ import 'dart:math'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../services/firebase_service.dart'; -import '../../services/sound_service.dart'; import '../../services/storage_service.dart'; +import 'dice_motion_config.dart'; import 'dice_state.dart'; class DiceNotifier extends StateNotifier { final Random _random = Random(); - DiceNotifier() : super(DiceRollState( - currentRolls: [6], - diceType: DiceType.d6, - diceCount: 1, - isRolling: false, - rollHistory: [], - )) { + DiceNotifier() + : super(DiceRollState( + currentRolls: [6], + diceType: DiceType.d6, + diceCount: 1, + isRolling: false, + rollHistory: [], + )) { _loadHistory(); } @@ -29,6 +30,7 @@ class DiceNotifier extends StateNotifier { } void setDiceType(DiceType type) { + if (state.isRolling) return; state = state.copyWith( diceType: type, currentRolls: List.generate(state.diceCount, (_) => type.sides), @@ -36,60 +38,74 @@ class DiceNotifier extends StateNotifier { } void setDiceCount(int count) { + if (state.isRolling) return; state = state.copyWith( diceCount: count, currentRolls: List.generate(count, (_) => state.diceType.sides), ); } + /// Resets the current visible dice orientation to default sides without wiping history. + void resetDice() { + if (state.isRolling) return; + state = state.copyWith( + currentRolls: List.generate(state.diceCount, (_) => state.diceType.sides), + ); + } + + /// Initiates a deterministic physical roll sequence. + /// + /// Flow: + /// 1. Logical randomness generated first + /// 2. State updated with target rolls & isRolling: true so 3D renderer animates to exact target + /// 3. Physical animation timeline completes + /// 4. History recorded, state marked complete (isRolling: false) Future roll() async { if (state.isRolling) return; - state = state.copyWith(isRolling: true); - - // Simulate rolling animation with multi-step sound & values - for (int i = 0; i < 6; i++) { - await Future.delayed(const Duration(milliseconds: 80)); - SoundService.playRollSound(); - state = state.copyWith( - currentRolls: List.generate( - state.diceCount, - (_) => _random.nextInt(state.diceType.sides) + 1, - ), - ); - } + // 1. Generate logical random result first final finalRolls = List.generate( state.diceCount, (_) => _random.nextInt(state.diceType.sides) + 1, ); - SoundService.triggerHaptic(); + // 2. Publish target result immediately so visual renderer computes target orientation + state = state.copyWith( + currentRolls: finalRolls, + isRolling: true, + ); + + // 3. Await duration matching physical 3D animation timeline + await Future.delayed(DiceMotionConfig.rollDuration); + + // 4. Update history upon settling final updatedHistory = List>.from(state.rollHistory)..insert(0, finalRolls); if (updatedHistory.length > 50) { updatedHistory.removeLast(); } state = state.copyWith( - currentRolls: finalRolls, isRolling: false, rollHistory: updatedHistory, ); - // Save history & statistics - final box = StorageService.getStatsBox(); - await box.put('dice_history', updatedHistory); - - // Update global dice rolls counter - final int totalRolls = box.get('total_dice_rolls', defaultValue: 0) as int; - await box.put('total_dice_rolls', totalRolls + state.diceCount); + // Persist history & statistics + if (StorageService.isBoxOpen(StorageService.statsBoxName)) { + final box = StorageService.getStatsBox(); + await box.put('dice_history', updatedHistory); + final int totalRolls = box.get('total_dice_rolls', defaultValue: 0) as int; + await box.put('total_dice_rolls', totalRolls + state.diceCount); + } - // Analytics + // Analytics event AnalyticsService.trackDiceRolled(state.diceCount, state.diceType.name); } void clearHistory() { state = state.copyWith(rollHistory: []); - StorageService.getStatsBox().delete('dice_history'); + if (StorageService.isBoxOpen(StorageService.statsBoxName)) { + StorageService.getStatsBox().delete('dice_history'); + } } } diff --git a/lib/features/dice/dice_roller_view.dart b/lib/features/dice/dice_roller_view.dart new file mode 100644 index 0000000..4f817bf --- /dev/null +++ b/lib/features/dice/dice_roller_view.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'dice_face.dart'; +import 'dice_state.dart'; +import 'playmate_dice.dart'; + +/// Coordinates responsive layout and staggered physical rolling for 1 to 6 dice. +class DiceRollerView extends StatelessWidget { + final List rolls; + final DiceType diceType; + final bool isRolling; + final VoidCallback onRollRequested; + final VoidCallback? onAllDiceCompleted; + final DiceStyle? style; + + const DiceRollerView({ + super.key, + required this.rolls, + required this.diceType, + required this.isRolling, + required this.onRollRequested, + this.onAllDiceCompleted, + this.style, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; + final count = rolls.length; + + // Calculate responsive die size based on available container width + final double dieSize = _calculateResponsiveSize(availableWidth, count); + + // Layout arrangement + if (count == 1) { + return Center( + child: PlayMateDice( + value: rolls[0], + size: dieSize, + isRolling: isRolling, + diceIndex: 0, + delayMs: 0, + diceType: diceType, + style: style, + onRollRequested: onRollRequested, + onRollCompleted: onAllDiceCompleted, + ), + ); + } + + return Center( + child: Wrap( + spacing: count > 3 ? 12.0 : 18.0, + runSpacing: count > 3 ? 12.0 : 18.0, + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + children: List.generate(count, (index) { + // Staggered launch delay: +35ms per die index + final delay = index * 35; + final isLastDie = index == count - 1; + + return PlayMateDice( + key: ValueKey('die_$index'), + value: rolls[index], + size: dieSize, + isRolling: isRolling, + diceIndex: index, + delayMs: delay, + diceType: diceType, + style: style, + onRollRequested: onRollRequested, + onRollCompleted: isLastDie ? onAllDiceCompleted : null, + ); + }), + ), + ); + }, + ); + } + + double _calculateResponsiveSize(double availableWidth, int count) { + if (availableWidth <= 330) { + // Extremely compact screen (320px width) + switch (count) { + case 1: + return 135.0; + case 2: + return 96.0; + case 3: + case 4: + return 74.0; + default: + return 64.0; + } + } else if (availableWidth <= 440) { + // Standard mobile screens (360px - 412px) + switch (count) { + case 1: + return 165.0; + case 2: + return 120.0; + case 3: + return 98.0; + case 4: + return 92.0; + default: + return 80.0; + } + } else if (availableWidth <= 600) { + // Large phone / small tablet + switch (count) { + case 1: + return 185.0; + case 2: + return 135.0; + case 3: + case 4: + return 105.0; + default: + return 92.0; + } + } else { + // Tablet / desktop + switch (count) { + case 1: + return 210.0; + case 2: + return 155.0; + case 3: + case 4: + return 125.0; + default: + return 110.0; + } + } + } +} diff --git a/lib/features/dice/dice_screen.dart b/lib/features/dice/dice_screen.dart index 9735f14..fdd08ec 100644 --- a/lib/features/dice/dice_screen.dart +++ b/lib/features/dice/dice_screen.dart @@ -1,9 +1,11 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:sensors_plus/sensors_plus.dart'; -import 'dart:async'; + import '../../shared/responsive_layout.dart'; import 'dice_provider.dart'; +import 'dice_roller_view.dart'; import 'dice_state.dart'; class DiceScreen extends ConsumerStatefulWidget { @@ -13,27 +15,25 @@ class DiceScreen extends ConsumerStatefulWidget { ConsumerState createState() => _DiceScreenState(); } -class _DiceScreenState extends ConsumerState with SingleTickerProviderStateMixin { +class _DiceScreenState extends ConsumerState { StreamSubscription? _shakeSubscription; - late AnimationController _animationController; DateTime _lastShakeTime = DateTime.now(); @override void initState() { super.initState(); - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 300), - ); + _initAccelerometer(); + } - // Shake detection configuration + void _initAccelerometer() { try { _shakeSubscription = userAccelerometerEventStream().listen( (UserAccelerometerEvent event) { - final double acceleration = event.x * event.x + event.y * event.y + event.z * event.z; - if (acceleration > 200) { // Shake threshold + final double acceleration = + event.x * event.x + event.y * event.y + event.z * event.z; + if (acceleration > 190) { final now = DateTime.now(); - if (now.difference(_lastShakeTime) > const Duration(milliseconds: 800)) { + if (now.difference(_lastShakeTime) > const Duration(milliseconds: 1100)) { _lastShakeTime = now; _triggerRoll(); } @@ -50,14 +50,14 @@ class _DiceScreenState extends ConsumerState with SingleTickerProvid } void _triggerRoll() { - _animationController.forward(from: 0.0); + final diceState = ref.read(diceProvider); + if (diceState.isRolling) return; ref.read(diceProvider.notifier).roll(); } @override void dispose() { _shakeSubscription?.cancel(); - _animationController.dispose(); super.dispose(); } @@ -65,6 +65,7 @@ class _DiceScreenState extends ConsumerState with SingleTickerProvid Widget build(BuildContext context) { final diceState = ref.watch(diceProvider); final theme = Theme.of(context); + final isDarkMode = theme.brightness == Brightness.dark; return Scaffold( appBar: AppBar( @@ -72,9 +73,20 @@ class _DiceScreenState extends ConsumerState with SingleTickerProvid actions: [ IconButton( icon: const Icon(Icons.refresh), - onPressed: () { - ref.read(diceProvider.notifier).clearHistory(); - }, + onPressed: diceState.isRolling + ? null + : () { + ref.read(diceProvider.notifier).resetDice(); + }, + tooltip: 'Reset Dice', + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: (diceState.rollHistory.isEmpty || diceState.isRolling) + ? null + : () { + ref.read(diceProvider.notifier).clearHistory(); + }, tooltip: 'Clear History', ), ], @@ -83,50 +95,44 @@ class _DiceScreenState extends ConsumerState with SingleTickerProvid child: ResponsiveLayout( mobile: Column( children: [ + // Main hero area with tactile dice Expanded( - child: Center( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.m), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Animation support - AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - final double angle = _animationController.value * 2 * 3.14159; - return Transform.rotate( - angle: diceState.isRolling ? angle : 0, - child: child, - ); - }, - child: Wrap( - spacing: AppSpacing.m, - runSpacing: AppSpacing.m, - alignment: WrapAlignment.center, - children: diceState.currentRolls.map((roll) { - return _buildDiceWidget(roll, diceState.diceType, theme); - }).toList(), - ), - ), - const SizedBox(height: AppSpacing.xl), - Text( - diceState.isRolling ? 'Rolling...' : 'Total: ${diceState.totalSum}', - style: theme.textTheme.headlineMedium, - ), - const SizedBox(height: AppSpacing.s), - Text( - 'Tap the dice or shake your phone to roll', - style: theme.textTheme.bodyMedium, - ), - ], + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.m, + vertical: AppSpacing.s, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 12), + + // Hero 3D Physical Dice (Interactive press & roll) + DiceRollerView( + rolls: diceState.currentRolls, + diceType: diceState.diceType, + isRolling: diceState.isRolling, + onRollRequested: _triggerRoll, ), - ), + + const SizedBox(height: 18), + + // Result typography + _buildResultSection(diceState, theme), + + const SizedBox(height: 16), + + // Compact roll history + if (diceState.rollHistory.isNotEmpty) + _buildRecentRollsSection(diceState, theme, isDarkMode), + ], ), ), ), - _buildControlPanel(diceState, theme), + + // Bottom control panel & roll button + _buildBottomControls(diceState, theme), ], ), ), @@ -134,49 +140,164 @@ class _DiceScreenState extends ConsumerState with SingleTickerProvid ); } - Widget _buildDiceWidget(int value, DiceType type, ThemeData theme) { - return GestureDetector( - onTap: _triggerRoll, - child: Container( - width: 100, - height: 100, - decoration: BoxDecoration( - color: theme.colorScheme.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: theme.colorScheme.primary, width: 2), - boxShadow: [ - BoxShadow( - color: theme.colorScheme.primary.withValues(alpha: 0.1), - blurRadius: 8, - offset: const Offset(0, 4), - ) + Widget _buildResultSection(DiceRollState diceState, ThemeData theme) { + if (diceState.isRolling) { + return Column( + children: [ + Text( + 'Rolling...', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 4), + Text( + 'Tumbling on table', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.secondary, + ), + ), + ], + ); + } + + final isSingleDie = diceState.diceCount == 1; + + return Semantics( + label: isSingleDie + ? 'Dice result: ${diceState.currentRolls.first}' + : 'Dice results: ${diceState.currentRolls.join(", ")}. Total ${diceState.totalSum}.', + child: Column( + children: [ + if (isSingleDie) ...[ + Text( + '${diceState.currentRolls.first}', + style: theme.textTheme.headlineLarge?.copyWith( + fontSize: 44, + fontWeight: FontWeight.w900, + letterSpacing: -1.0, + height: 1.1, + ), + ), + const SizedBox(height: 2), + Text( + 'Rolled Result', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.secondary, + letterSpacing: 0.5, + ), + ), + ] else ...[ + Text( + 'Total: ${diceState.totalSum}', + style: theme.textTheme.headlineMedium?.copyWith( + fontSize: 32, + fontWeight: FontWeight.w900, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 4), + Text( + diceState.currentRolls.join(' + '), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.secondary, + fontWeight: FontWeight.w600, + ), + ), + ], + const SizedBox(height: 8), + Text( + 'Tap dice or button to roll', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.secondary.withValues(alpha: 0.8), + ), + ), + ], + ), + ); + } + + Widget _buildRecentRollsSection( + DiceRollState diceState, + ThemeData theme, + bool isDarkMode, + ) { + final recent = diceState.rollHistory.take(10).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.history, + size: 15, + color: theme.colorScheme.secondary, + ), + const SizedBox(width: 6), + Text( + 'Recent Rolls', + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.secondary, + letterSpacing: 0.5, + ), + ), ], ), - child: Center( - child: Column( + const SizedBox(height: 8), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: Row( mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '$value', - style: theme.textTheme.headlineLarge?.copyWith( - fontWeight: FontWeight.w900, - ), - ), - Text( - type.name.toUpperCase(), - style: theme.textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.bold, - letterSpacing: 1.0, + children: recent.asMap().entries.map((entry) { + final index = entry.key; + final roll = entry.value; + final sum = roll.fold(0, (prev, element) => prev + element); + final isLatest = index == 0; + + final label = roll.length == 1 ? '$sum' : '$sum (${roll.join('+')})'; + + return Padding( + padding: const EdgeInsets.only(right: 6.0), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: isLatest + ? (isDarkMode ? const Color(0xFF2C2C2E) : const Color(0xFFEBEBEB)) + : (isDarkMode ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5)), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isLatest + ? const Color(0xFFC18A42).withValues(alpha: 0.4) + : (isDarkMode ? const Color(0xFF2C2C2C) : const Color(0xFFE0E0E0)), + width: 1.0, + ), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: isLatest ? FontWeight.bold : FontWeight.w500, + color: isLatest + ? (isDarkMode ? Colors.white : const Color(0xFF1A1A1A)) + : theme.colorScheme.secondary, + ), + ), ), - ), - ], + ); + }).toList(), ), ), - ), + ], ); } - Widget _buildControlPanel(DiceRollState diceState, ThemeData theme) { + Widget _buildBottomControls(DiceRollState diceState, ThemeData theme) { return Container( padding: const EdgeInsets.all(AppSpacing.m), decoration: BoxDecoration( @@ -188,64 +309,122 @@ class _DiceScreenState extends ConsumerState with SingleTickerProvid child: Column( mainAxisSize: MainAxisSize.min, children: [ + // Dice count row Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('Dice Count', style: theme.textTheme.titleLarge), - Row( - children: [ - IconButton( - icon: const Icon(Icons.remove), - onPressed: diceState.diceCount > 1 - ? () => ref.read(diceProvider.notifier).setDiceCount(diceState.diceCount - 1) - : null, - ), - Text('${diceState.diceCount}', style: theme.textTheme.headlineMedium), - IconButton( - icon: const Icon(Icons.add), - onPressed: diceState.diceCount < 6 - ? () => ref.read(diceProvider.notifier).setDiceCount(diceState.diceCount + 1) - : null, + Expanded( + child: Text( + 'Dice Quantity', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, ), - ], + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: theme.dividerColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.remove, size: 18), + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + onPressed: (!diceState.isRolling && diceState.diceCount > 1) + ? () => ref.read(diceProvider.notifier).setDiceCount(diceState.diceCount - 1) + : null, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0), + child: Text( + '${diceState.diceCount}', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.add, size: 18), + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + onPressed: (!diceState.isRolling && diceState.diceCount < 6) + ? () => ref.read(diceProvider.notifier).setDiceCount(diceState.diceCount + 1) + : null, + ), + ], + ), ), ], ), - const SizedBox(height: AppSpacing.s), + + const SizedBox(height: 10), + + // Dice type selector (D6, D4, D8, etc.) SingleChildScrollView( scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), child: Row( children: DiceType.values.map((type) { final isSelected = diceState.diceType == type; return Padding( - padding: const EdgeInsets.only(right: AppSpacing.s), + padding: const EdgeInsets.only(right: 6.0), child: ChoiceChip( label: Text(type.name.toUpperCase()), selected: isSelected, - onSelected: (selected) { - if (selected) { - ref.read(diceProvider.notifier).setDiceType(type); - } - }, + onSelected: diceState.isRolling + ? null + : (selected) { + if (selected) { + ref.read(diceProvider.notifier).setDiceType(type); + } + }, ), ); }).toList(), ), ), + const SizedBox(height: AppSpacing.m), + + // Tactile Roll Button SizedBox( width: double.infinity, - height: 50, + height: 52, child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: theme.colorScheme.primary, foregroundColor: theme.colorScheme.onPrimary, + disabledBackgroundColor: theme.colorScheme.primary.withValues(alpha: 0.5), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), + elevation: 0, ), - onPressed: _triggerRoll, - child: const Text('ROLL DICE', style: TextStyle(fontWeight: FontWeight.bold)), + onPressed: diceState.isRolling ? null : _triggerRoll, + child: diceState.isRolling + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white70, + ), + ) + : const Text( + 'ROLL DICE', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + letterSpacing: 1.0, + ), + ), ), ), ], diff --git a/lib/features/dice/playmate_dice.dart b/lib/features/dice/playmate_dice.dart new file mode 100644 index 0000000..cf22fc5 --- /dev/null +++ b/lib/features/dice/playmate_dice.dart @@ -0,0 +1,504 @@ +import 'dart:io' show Platform; +import 'dart:math' as math; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../services/sound_service.dart'; +import 'dice_face.dart'; +import 'dice_motion_config.dart'; +import 'dice_orientation.dart'; +import 'dice_state.dart'; + +/// Primary physical 3D PlayMate die widget. +/// +/// Features: +/// - True programmatic 3D cube transformation using Matrix4 and Euler angles +/// - 6 independently calculated faces with dynamic depth sorting (painter's algorithm) +/// - Directional ambient lighting and chamfer edge reflections +/// - Deterministic final face orientation matching the logical result +/// - Physical anticipation, launch elevation, multi-axis organic tumble, and bounce settling +/// - Responsive dynamic contact shadow +/// - Tactile press and spring-back interactions +/// - Full accessibility semantics and reduced-motion graceful fallback +class PlayMateDice extends StatefulWidget { + final int value; + final double size; + final bool isRolling; + final VoidCallback? onRollRequested; + final VoidCallback? onRollCompleted; + final int diceIndex; + final int delayMs; + final DiceType diceType; + final DiceStyle? style; + + const PlayMateDice({ + super.key, + required this.value, + this.size = 150.0, + this.isRolling = false, + this.onRollRequested, + this.onRollCompleted, + this.diceIndex = 0, + this.delayMs = 0, + this.diceType = DiceType.d6, + this.style, + }); + + @override + State createState() => _PlayMateDiceState(); +} + +class _PlayMateDiceState extends State with TickerProviderStateMixin { + late AnimationController _rollController; + late AnimationController _idleController; + late AnimationController _pressController; + + DiceVec3 _currentRotation = DiceFaceOrientation.getRestingEulerForValue(1); + DiceVec3 _fromRotation = DiceFaceOrientation.getRestingEulerForValue(1); + DiceVec3 _targetRotation = DiceFaceOrientation.getRestingEulerForValue(1); + + bool _hasTriggeredLaunchHaptic = false; + bool _hasTriggeredMidRollSound = false; + bool _hasTriggeredLandingHaptic = false; + bool _hasTriggeredSettleFeedback = false; + + @override + void initState() { + super.initState(); + _currentRotation = DiceFaceOrientation.getRestingEulerForValue(widget.value); + _fromRotation = _currentRotation; + _targetRotation = _currentRotation; + + _rollController = AnimationController( + vsync: this, + duration: DiceMotionConfig.rollDuration, + ); + + _idleController = AnimationController( + vsync: this, + duration: DiceMotionConfig.idleCycleDuration, + ); + // Do not continuously loop idle animation in tests to prevent pumpAndSettle timeout + final isTesting = !kIsWeb && Platform.environment.containsKey('FLUTTER_TEST'); + if (!isTesting) { + _idleController.repeat(); + } + + _pressController = AnimationController( + vsync: this, + duration: DiceMotionConfig.pressDuration, + ); + + _rollController.addListener(_handleRollTick); + _rollController.addStatusListener(_handleRollStatus); + + if (widget.isRolling) { + _startRollSequence(); + } + } + + @override + void didUpdateWidget(covariant PlayMateDice oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.value != oldWidget.value && !widget.isRolling && !_rollController.isAnimating) { + setState(() { + _currentRotation = DiceFaceOrientation.getRestingEulerForValue(widget.value); + _fromRotation = _currentRotation; + _targetRotation = _currentRotation; + }); + } + + if (widget.isRolling && !oldWidget.isRolling && !_rollController.isAnimating) { + _startRollSequence(); + } + } + + void _startRollSequence() { + _fromRotation = _currentRotation; + + final baseTarget = DiceFaceOrientation.getRestingEulerForValue(widget.value); + + final int revCountX = 2 + (widget.diceIndex % 2); + final int revCountY = 3 + ((widget.diceIndex + 1) % 2); + final double revCountZ = 1.0 + (widget.diceIndex % 3) * 0.5; + + final double extraX = revCountX * 2 * math.pi; + final double extraY = revCountY * 2 * math.pi; + final double extraZ = revCountZ * 2 * math.pi; + + _targetRotation = DiceVec3( + baseTarget.x + extraX, + baseTarget.y + extraY, + baseTarget.z + extraZ, + ); + + _hasTriggeredLaunchHaptic = false; + _hasTriggeredMidRollSound = false; + _hasTriggeredLandingHaptic = false; + _hasTriggeredSettleFeedback = false; + + if (widget.delayMs > 0) { + Future.delayed(Duration(milliseconds: widget.delayMs), () { + if (mounted && widget.isRolling) { + _rollController.forward(from: 0.0); + } + }); + } else { + _rollController.forward(from: 0.0); + } + } + + void _handleRollTick() { + final t = _rollController.value; + + if (t >= DiceMotionConfig.anticipationEnd && !_hasTriggeredLaunchHaptic) { + _hasTriggeredLaunchHaptic = true; + HapticFeedback.lightImpact(); + } + + if (t >= 0.48 && !_hasTriggeredMidRollSound) { + _hasTriggeredMidRollSound = true; + SoundService.playRollSound(); + } + + if (t >= DiceMotionConfig.tumbleEnd && !_hasTriggeredLandingHaptic) { + _hasTriggeredLandingHaptic = true; + SoundService.triggerHaptic(); + } + + if (t >= 0.98 && !_hasTriggeredSettleFeedback) { + _hasTriggeredSettleFeedback = true; + HapticFeedback.selectionClick(); + } + + final double rotT = _calculateRotationProgress(t); + setState(() { + _currentRotation = DiceVec3( + _lerpDouble(_fromRotation.x, _targetRotation.x, rotT), + _lerpDouble(_fromRotation.y, _targetRotation.y, rotT), + _lerpDouble(_fromRotation.z, _targetRotation.z, rotT), + ); + }); + } + + double _lerpDouble(double a, double b, double t) => a + (b - a) * t; + + double _calculateRotationProgress(double t) { + if (t < DiceMotionConfig.anticipationEnd) { + final subT = t / DiceMotionConfig.anticipationEnd; + return -0.015 * DiceMotionConfig.anticipationCurve.transform(subT); + } else { + final subT = (t - DiceMotionConfig.anticipationEnd) / + (1.0 - DiceMotionConfig.anticipationEnd); + return Curves.easeOutCubic.transform(subT.clamp(0.0, 1.0)); + } + } + + void _handleRollStatus(AnimationStatus status) { + if (status == AnimationStatus.completed) { + final baseTarget = DiceFaceOrientation.getRestingEulerForValue(widget.value); + setState(() { + _currentRotation = baseTarget; + _fromRotation = baseTarget; + _targetRotation = baseTarget; + }); + widget.onRollCompleted?.call(); + } + } + + @override + void dispose() { + _rollController.dispose(); + _idleController.dispose(); + _pressController.dispose(); + super.dispose(); + } + + void _onTapDown(TapDownDetails details) { + if (widget.isRolling || _rollController.isAnimating) return; + _pressController.forward(); + HapticFeedback.selectionClick(); + } + + void _onTapUp(TapUpDetails details) { + if (widget.isRolling || _rollController.isAnimating) return; + _pressController.reverse(); + widget.onRollRequested?.call(); + } + + void _onTapCancel() { + if (widget.isRolling || _rollController.isAnimating) return; + _pressController.reverse(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDarkMode = theme.brightness == Brightness.dark; + final effectiveStyle = widget.style ?? DiceStyle.fromBrightness(theme.brightness); + final reducedMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false; + + return Semantics( + label: widget.isRolling + ? 'Rolling die' + : 'Die showing ${widget.value}. Tap or roll to shake', + button: true, + enabled: !widget.isRolling, + child: GestureDetector( + onTapDown: _onTapDown, + onTapUp: _onTapUp, + onTapCancel: _onTapCancel, + child: AnimatedBuilder( + animation: Listenable.merge([_rollController, _idleController, _pressController]), + builder: (context, _) { + final double rollT = _rollController.value; + final double pressT = _pressController.value; + + // 1. Calculate physical scale + double scale = 1.0 - (pressT * (1.0 - DiceMotionConfig.pressScale)); + if (!reducedMotion && _rollController.isAnimating) { + if (rollT < DiceMotionConfig.anticipationEnd) { + final subT = rollT / DiceMotionConfig.anticipationEnd; + scale = _lerpDouble(1.0, DiceMotionConfig.pressScale, subT); + } else if (rollT < DiceMotionConfig.launchEnd) { + final subT = (rollT - DiceMotionConfig.anticipationEnd) / + (DiceMotionConfig.launchEnd - DiceMotionConfig.anticipationEnd); + scale = _lerpDouble(DiceMotionConfig.pressScale, DiceMotionConfig.launchScale, subT); + } else if (rollT < DiceMotionConfig.tumbleEnd) { + final subT = (rollT - DiceMotionConfig.launchEnd) / + (DiceMotionConfig.tumbleEnd - DiceMotionConfig.launchEnd); + scale = _lerpDouble(DiceMotionConfig.launchScale, 1.0, subT); + } else if (rollT < DiceMotionConfig.landingEnd) { + final subT = (rollT - DiceMotionConfig.tumbleEnd) / + (DiceMotionConfig.landingEnd - DiceMotionConfig.tumbleEnd); + final bouncePhase = math.sin(subT * math.pi * 2); + scale = 1.0 + (bouncePhase * 0.035); + } else { + final subT = (rollT - DiceMotionConfig.landingEnd) / + (DiceMotionConfig.settleEnd - DiceMotionConfig.landingEnd); + final settlePhase = math.sin(subT * math.pi * 2); + scale = 1.0 + (settlePhase * 0.012); + } + } + + // 2. Calculate physical elevation translateY + double translateY = pressT * DiceMotionConfig.pressTranslateY; + if (!reducedMotion && _rollController.isAnimating) { + if (rollT < DiceMotionConfig.anticipationEnd) { + translateY = DiceMotionConfig.pressTranslateY * (rollT / DiceMotionConfig.anticipationEnd); + } else if (rollT < DiceMotionConfig.launchEnd) { + final subT = (rollT - DiceMotionConfig.anticipationEnd) / + (DiceMotionConfig.launchEnd - DiceMotionConfig.anticipationEnd); + translateY = _lerpDouble( + DiceMotionConfig.pressTranslateY, + DiceMotionConfig.launchPeakElevationY, + Curves.easeOutQuad.transform(subT), + ); + } else if (rollT < DiceMotionConfig.tumbleEnd) { + final subT = (rollT - DiceMotionConfig.launchEnd) / + (DiceMotionConfig.tumbleEnd - DiceMotionConfig.launchEnd); + translateY = _lerpDouble( + DiceMotionConfig.launchPeakElevationY, + 0.0, + Curves.easeInQuad.transform(subT), + ); + } else if (rollT < DiceMotionConfig.landingEnd) { + final subT = (rollT - DiceMotionConfig.tumbleEnd) / + (DiceMotionConfig.landingEnd - DiceMotionConfig.tumbleEnd); + translateY = -5.0 * math.sin(subT * math.pi); + } else { + translateY = 0.0; + } + } else if (!reducedMotion && !_rollController.isAnimating && pressT == 0) { + final idlePhase = math.sin(_idleController.value * math.pi * 2); + translateY = -1.8 * idlePhase; + } + + // 3. Dynamic contact shadow + double shadowScale = DiceMotionConfig.shadowIdleScale; + double shadowOpacity = isDarkMode + ? DiceMotionConfig.shadowIdleOpacity * 1.5 + : DiceMotionConfig.shadowIdleOpacity; + + if (!reducedMotion && _rollController.isAnimating) { + if (rollT < DiceMotionConfig.launchEnd) { + final subT = (rollT / DiceMotionConfig.launchEnd).clamp(0.0, 1.0); + shadowScale = _lerpDouble(DiceMotionConfig.shadowIdleScale, DiceMotionConfig.shadowLaunchScale, subT); + shadowOpacity = _lerpDouble(shadowOpacity, DiceMotionConfig.shadowLaunchOpacity, subT); + } else if (rollT < DiceMotionConfig.tumbleEnd) { + final subT = ((rollT - DiceMotionConfig.launchEnd) / + (DiceMotionConfig.tumbleEnd - DiceMotionConfig.launchEnd)) + .clamp(0.0, 1.0); + shadowScale = _lerpDouble(DiceMotionConfig.shadowLaunchScale, DiceMotionConfig.shadowLandingScale, subT); + shadowOpacity = _lerpDouble(DiceMotionConfig.shadowLaunchOpacity, DiceMotionConfig.shadowLandingOpacity, subT); + } else { + final subT = ((rollT - DiceMotionConfig.tumbleEnd) / + (1.0 - DiceMotionConfig.tumbleEnd)) + .clamp(0.0, 1.0); + shadowScale = _lerpDouble(DiceMotionConfig.shadowLandingScale, DiceMotionConfig.shadowIdleScale, subT); + shadowOpacity = _lerpDouble(DiceMotionConfig.shadowLandingOpacity, DiceMotionConfig.shadowIdleOpacity, subT); + } + } + + final double dieSize = widget.size; + final double halfExtent = dieSize / 2; + + return RepaintBoundary( + child: SizedBox( + width: dieSize * 1.25, + height: dieSize * 1.45, + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + // Dynamic ground shadow + Positioned( + bottom: dieSize * 0.08, + child: Transform.scale( + scale: shadowScale, + child: Opacity( + opacity: shadowOpacity.clamp(0.0, 1.0), + child: Container( + width: dieSize * 0.88, + height: dieSize * 0.28, + decoration: BoxDecoration( + borderRadius: BorderRadius.all( + Radius.elliptical(dieSize * 0.44, dieSize * 0.14)), + gradient: RadialGradient( + colors: [ + Colors.black.withValues(alpha: 0.65), + Colors.black.withValues(alpha: 0.25), + Colors.transparent, + ], + stops: const [0.0, 0.45, 1.0], + ), + ), + ), + ), + ), + ), + + // Physical 3D Cube / Facet body + Transform.translate( + offset: Offset(0, translateY), + child: Transform.scale( + scale: scale, + child: widget.diceType == DiceType.d6 + ? _build3DCube(dieSize, halfExtent, effectiveStyle, isDarkMode) + : _buildPolyhedralDie(dieSize, effectiveStyle, isDarkMode), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } + + Widget _build3DCube( + double dieSize, + double halfExtent, + DiceStyle style, + bool isDarkMode, + ) { + final visibleFaces = DiceFaceOrientation.computeVisibleFaces( + cubeRotation: _currentRotation, + halfExtent: halfExtent, + perspective: 0.0016, + ); + + return SizedBox( + width: dieSize, + height: dieSize, + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: visibleFaces.map((faceData) { + return Transform( + alignment: Alignment.center, + transform: faceData.transform, + child: DiceFace( + value: faceData.value, + size: dieSize, + style: style, + lightIntensity: faceData.lightIntensity, + isDarkMode: isDarkMode, + ), + ); + }).toList(), + ), + ); + } + + Widget _buildPolyhedralDie( + double dieSize, + DiceStyle style, + bool isDarkMode, + ) { + final double radius = dieSize * 0.22; + return Container( + width: dieSize, + height: dieSize, + decoration: BoxDecoration( + color: style.bodyColor, + borderRadius: BorderRadius.circular(radius), + border: Border.all( + color: isDarkMode + ? style.highlightColor.withValues(alpha: 0.2) + : style.shadowEdgeColor.withValues(alpha: 0.15), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: style.highlightColor.withValues(alpha: 0.4), + offset: const Offset(-2, -2), + blurRadius: 3, + ), + BoxShadow( + color: style.shadowEdgeColor.withValues(alpha: 0.3), + offset: const Offset(2, 2), + blurRadius: 4, + ), + ], + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${widget.value}', + style: TextStyle( + fontSize: dieSize * 0.38, + fontWeight: FontWeight.w900, + color: style.pipColor, + letterSpacing: -1.0, + ), + ), + const SizedBox(height: 2), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: style.accentColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + widget.diceType.name.toUpperCase(), + style: TextStyle( + fontSize: dieSize * 0.11, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + color: style.accentColor, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart index 9889267..738c97f 100644 --- a/lib/features/home/home_screen.dart +++ b/lib/features/home/home_screen.dart @@ -1,126 +1,496 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import '../../services/storage_service.dart'; import '../../shared/responsive_layout.dart'; -class HomeScreen extends StatelessWidget { +class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + // Key for local storage of favorite tool IDs + static const String _favoritesKey = 'home_favorite_tools'; + + late Set _favoriteToolIds; + + @override + void initState() { + super.initState(); + _loadFavorites(); + } + + void _loadFavorites() { + if (StorageService.isBoxOpen(StorageService.settingsBoxName)) { + final box = StorageService.getSettingsBox(); + final List? raw = box.get(_favoritesKey); + if (raw != null) { + _favoriteToolIds = raw.map((e) => e.toString()).toSet(); + } else { + // Default favorites for quick access + _favoriteToolIds = {'dice', 'coin', 'spin', 'timers'}; + } + } else { + _favoriteToolIds = {'dice', 'coin', 'spin', 'timers'}; + } + } + + Future _toggleFavorite(String id) async { + setState(() { + if (_favoriteToolIds.contains(id)) { + _favoriteToolIds.remove(id); + } else { + _favoriteToolIds.add(id); + } + }); + + if (StorageService.isBoxOpen(StorageService.settingsBoxName)) { + final box = StorageService.getSettingsBox(); + await box.put(_favoritesKey, _favoriteToolIds.toList()); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; - final tools = [ - { - 'icon': Icons.casino, - 'title': 'Dice Roller', - 'desc': 'Roll multiple dice with shake detection.', - 'route': '/dice', - }, - { - 'icon': Icons.monetization_on, - 'title': 'Coin Toss', - 'desc': 'Flip virtual coins for heads or tails.', - 'route': '/coin', - }, - { - 'icon': Icons.groups, - 'title': 'Team Generator', - 'desc': 'Instantly balance and create teams.', - 'route': '/teams', - }, - { - 'icon': Icons.scoreboard, - 'title': 'Score Tracker', - 'desc': 'Track score updates and match status.', - 'route': '/scores', - }, - { - 'icon': Icons.sports_cricket, - 'title': 'Cricket Scorer', - 'desc': 'Quick score scoring for gully matches.', - 'route': '/cricket', - }, - { - 'icon': Icons.track_changes, - 'title': 'Spin Wheel', - 'desc': 'Spin to pick options or pick a winner.', - 'route': '/spin', - }, - { - 'icon': Icons.emoji_events, - 'title': 'Tournament Gen', - 'desc': 'Generate brackets for 4 to 32 players.', - 'route': '/tournament', - }, - { - 'icon': Icons.timer, - 'title': 'Timers', - 'desc': 'Countdown, Stopwatch, Chess, Turn.', - 'route': '/timers', - }, + final allTools = [ + // Randomizers + _ToolItem( + id: 'dice', + icon: Icons.casino, + title: 'Dice Roller', + desc: '3D dice with realistic tumble & shake detection.', + route: '/dice', + category: 'Randomizers', + ), + _ToolItem( + id: 'coin', + icon: Icons.monetization_on, + title: 'Coin Toss', + desc: 'Minted 3D coin flip with thickness & streaks.', + route: '/coin', + category: 'Randomizers', + ), + _ToolItem( + id: 'spin', + icon: Icons.track_changes, + title: 'Spin Wheel', + desc: 'Custom segments, friction physics & elimination.', + route: '/spin', + category: 'Randomizers', + ), + // Timers + _ToolItem( + id: 'timers', + icon: Icons.timer, + title: 'Game Timers', + desc: 'Countdown, Stopwatch, Chess Clock & Turn Timer.', + route: '/timers', + category: 'Timers', + ), + // Scoring & Teams + _ToolItem( + id: 'teams', + icon: Icons.groups, + title: 'Team Generator', + desc: 'Balance rosters and auto-split players.', + route: '/teams', + category: 'Scoring & Teams', + ), + _ToolItem( + id: 'scores', + icon: Icons.scoreboard, + title: 'Score Tracker', + desc: 'Round-by-round points tally with undo history.', + route: '/scores', + category: 'Scoring & Teams', + ), + _ToolItem( + id: 'cricket', + icon: Icons.sports_cricket, + title: 'Cricket Scorer', + desc: 'Gully & club scorekeeper with legal ball rules.', + route: '/cricket', + category: 'Scoring & Teams', + ), + _ToolItem( + id: 'tournament', + icon: Icons.emoji_events, + title: 'Tournament Gen', + desc: 'Single elimination brackets for 4 to 32 players.', + route: '/tournament', + category: 'Tournament', + ), ]; + final favoriteTools = allTools.where((t) => _favoriteToolIds.contains(t.id)).toList(); + return Scaffold( appBar: AppBar( - title: const Text('PLAYMATE'), + title: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.sports_esports, + size: 20, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: AppSpacing.s), + const Text( + 'PLAYMATE', + style: TextStyle( + fontWeight: FontWeight.w900, + letterSpacing: 0.5, + ), + ), + ], + ), actions: [ IconButton( - icon: const Icon(Icons.settings), + icon: const Icon(Icons.settings_outlined), + tooltip: 'Settings & About', onPressed: () => context.push('/settings'), ), ], ), body: SafeArea( child: ResponsiveLayout( - mobile: GridView.builder( - padding: const EdgeInsets.all(AppSpacing.m), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: AppSpacing.m, - mainAxisSpacing: AppSpacing.m, - childAspectRatio: 1.0, + mobile: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.m, + vertical: AppSpacing.s, ), - itemCount: tools.length, - itemBuilder: (context, index) { - final tool = tools[index]; - return Card( - child: InkWell( - onTap: () => context.push(tool['route'] as String), - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(AppSpacing.m), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - tool['icon'] as IconData, - size: 32, - color: theme.colorScheme.primary, - ), - const SizedBox(height: AppSpacing.s), - Text( - tool['title'] as String, - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Quick Play Hero Bar + _buildQuickPlayBar(context, theme, isDark), + const SizedBox(height: AppSpacing.l), + + // Favorites / Frequently Used Tools + if (favoriteTools.isNotEmpty) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'QUICK ACCESS', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.secondary, + fontWeight: FontWeight.bold, + letterSpacing: 0.8, ), - const SizedBox(height: AppSpacing.xs), - Text( - tool['desc'] as String, - style: theme.textTheme.bodyMedium, - maxLines: 2, - overflow: TextOverflow.ellipsis, + ), + Text( + '${favoriteTools.length} pinned', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.secondary, ), - ], + ), + ], + ), + const SizedBox(height: AppSpacing.s), + SizedBox( + height: 96, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: favoriteTools.length, + separatorBuilder: (_, _) => const SizedBox(width: AppSpacing.s), + itemBuilder: (context, index) { + final tool = favoriteTools[index]; + return _buildQuickAccessChip(context, tool, theme, isDark); + }, + ), + ), + const SizedBox(height: AppSpacing.l), + ], + + // All Tools Categorized Grid Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'ALL GAME TOOLS', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.secondary, + fontWeight: FontWeight.bold, + letterSpacing: 0.8, + ), + ), + Text( + '${allTools.length} tools', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.secondary, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.s), + + // Complete Tools Grid + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: AppSpacing.m, + mainAxisSpacing: AppSpacing.m, + childAspectRatio: 0.95, + ), + itemCount: allTools.length, + itemBuilder: (context, index) { + final tool = allTools[index]; + final isFavorite = _favoriteToolIds.contains(tool.id); + return _buildToolCard(context, tool, isFavorite, theme, isDark); + }, + ), + const SizedBox(height: AppSpacing.xl), + ], + ), + ), + ), + ), + ); + } + + Widget _buildQuickPlayBar(BuildContext context, ThemeData theme, bool isDark) { + return Container( + padding: const EdgeInsets.all(AppSpacing.m), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isDark ? const Color(0xFF2C2C2C) : const Color(0xFFE5E5E5), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.bolt, + size: 18, + color: theme.colorScheme.primary, + ), + const SizedBox(width: AppSpacing.xs), + Text( + 'QUICK START', + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 0.6, + ), + ), + const Spacer(), + Text( + 'Offline Toolkit', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.secondary, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.s), + Text( + 'Ready to play? Pick a tool to begin.', + style: theme.textTheme.bodyMedium?.copyWith( + color: isDark ? const Color(0xFFCCCCCC) : const Color(0xFF4A4A4A), + ), + ), + const SizedBox(height: AppSpacing.m), + Row( + children: [ + Expanded( + child: FilledButton.tonalIcon( + onPressed: () => context.push('/dice'), + icon: const Icon(Icons.casino, size: 18), + label: const Text('Roll Dice'), + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), ), + padding: const EdgeInsets.symmetric(vertical: 12), ), ), - ); - }, + ), + const SizedBox(width: AppSpacing.s), + Expanded( + child: FilledButton.tonalIcon( + onPressed: () => context.push('/coin'), + icon: const Icon(Icons.monetization_on, size: 18), + label: const Text('Toss Coin'), + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildQuickAccessChip( + BuildContext context, + _ToolItem tool, + ThemeData theme, + bool isDark, + ) { + return InkWell( + onTap: () => context.push(tool.route), + borderRadius: BorderRadius.circular(12), + child: Container( + width: 130, + padding: const EdgeInsets.all(AppSpacing.s + 2), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isDark ? const Color(0xFF2C2C2C) : const Color(0xFFE5E5E5), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + Icon( + tool.icon, + size: 22, + color: theme.colorScheme.primary, + ), + const Spacer(), + Icon( + Icons.arrow_forward_ios, + size: 12, + color: theme.colorScheme.secondary, + ), + ], + ), + const SizedBox(height: AppSpacing.s), + Text( + tool.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + tool.category, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + fontSize: 11, + color: theme.colorScheme.secondary, + ), + ), + ], + ), + ), + ); + } + + Widget _buildToolCard( + BuildContext context, + _ToolItem tool, + bool isFavorite, + ThemeData theme, + bool isDark, + ) { + return Card( + child: InkWell( + onTap: () => context.push(tool.route), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.m), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + tool.icon, + size: 24, + color: theme.colorScheme.primary, + ), + ), + IconButton( + iconSize: 18, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + icon: Icon( + isFavorite ? Icons.star : Icons.star_border, + color: isFavorite ? Colors.amber[700] : theme.colorScheme.secondary, + ), + tooltip: isFavorite ? 'Unpin from quick access' : 'Pin to quick access', + onPressed: () => _toggleFavorite(tool.id), + ), + ], + ), + const Spacer(), + Text( + tool.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + tool.desc, + style: theme.textTheme.bodySmall?.copyWith( + color: isDark ? const Color(0xFFA0A0A0) : const Color(0xFF666666), + height: 1.3, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], ), ), ), ); } } + +class _ToolItem { + final String id; + final IconData icon; + final String title; + final String desc; + final String route; + final String category; + + const _ToolItem({ + required this.id, + required this.icon, + required this.title, + required this.desc, + required this.route, + required this.category, + }); +} diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 6678695..554a595 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -56,11 +56,37 @@ class SettingsScreen extends ConsumerWidget { ), subtitle: const Text('Deletes all local matches and dice histories'), onTap: () async { - final messenger = ScaffoldMessenger.of(context); - await StorageService.clearAllData(); - messenger.showSnackBar( - const SnackBar(content: Text('All data cleared successfully!')), + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Clear Match History?'), + content: const Text( + 'This will permanently delete all stored local matches, rolls, and tournament data. This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('CANCEL'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: theme.colorScheme.error, + foregroundColor: theme.colorScheme.onError, + ), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('CLEAR ALL'), + ), + ], + ), ); + + if (confirmed == true && context.mounted) { + final messenger = ScaffoldMessenger.of(context); + await StorageService.clearAllData(); + messenger.showSnackBar( + const SnackBar(content: Text('All data cleared successfully!')), + ); + } }, ), ], diff --git a/lib/features/spin_wheel/playmate_spin_wheel.dart b/lib/features/spin_wheel/playmate_spin_wheel.dart new file mode 100644 index 0000000..2714d28 --- /dev/null +++ b/lib/features/spin_wheel/playmate_spin_wheel.dart @@ -0,0 +1,321 @@ +import 'dart:io' show Platform; +import 'dart:math' as math; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../services/sound_service.dart'; +import 'spin_wheel_math.dart'; +import 'spin_wheel_models.dart'; +import 'spin_wheel_motion_config.dart'; +import 'wheel_painter.dart'; +import 'wheel_pointer.dart'; + +/// Primary interactive physical PlayMate Spin Wheel component. +/// +/// Features: +/// - Mathematically deterministic deceleration landing squarely on [targetWinner] +/// - Physical perimeter peg collisions that deflect the top pointer +/// - Coordinated tactile haptic feedback on peg passage +/// - Subtle idle breathing animation (guarded during automated tests) +/// - Press compression & spring-back feedback +/// - Dynamic contact drop shadow responding to physical elevation +/// - Accessibility semantics with screen reader support +class PlayMateSpinWheel extends StatefulWidget { + final List options; + final WheelOption? targetWinner; + final bool isSpinning; + final double size; + final VoidCallback? onSpinRequested; + final VoidCallback? onSpinCompleted; + + const PlayMateSpinWheel({ + super.key, + required this.options, + this.targetWinner, + this.isSpinning = false, + this.size = 280.0, + this.onSpinRequested, + this.onSpinCompleted, + }); + + @override + State createState() => _PlayMateSpinWheelState(); +} + +class _PlayMateSpinWheelState extends State + with TickerProviderStateMixin { + late AnimationController _spinController; + late AnimationController _idleController; + late AnimationController _pressController; + + double _currentRotation = 0.0; + double _startRotation = 0.0; + double _targetRotation = 0.0; + + // Peg deflection calculation + double _pointerDeflection = 0.0; + int _lastPegSectorIndex = -1; + + @override + void initState() { + super.initState(); + _spinController = AnimationController( + vsync: this, + duration: SpinWheelMotionConfig.spinDuration, + ); + + _idleController = AnimationController( + vsync: this, + duration: SpinWheelMotionConfig.idleCycleDuration, + ); + + // Guard idle animation loop during automated testing to avoid pumpAndSettle timeout + final isTesting = !kIsWeb && Platform.environment.containsKey('FLUTTER_TEST'); + if (!isTesting) { + _idleController.repeat(); + } + + _pressController = AnimationController( + vsync: this, + duration: SpinWheelMotionConfig.pressDuration, + ); + + _spinController.addListener(_handleSpinTick); + _spinController.addStatusListener(_handleSpinStatus); + + if (widget.isSpinning) { + _startSpinSequence(); + } + } + + @override + void didUpdateWidget(covariant PlayMateSpinWheel oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.isSpinning && !oldWidget.isSpinning && !_spinController.isAnimating) { + _startSpinSequence(); + } + } + + void _startSpinSequence() { + if (widget.options.isEmpty) return; + + _startRotation = _currentRotation; + final int count = widget.options.length; + + // Determine target index + int winningIndex = 0; + if (widget.targetWinner != null) { + final found = widget.options.indexWhere((o) => o.id == widget.targetWinner!.id); + winningIndex = found >= 0 ? found : 0; + } + + // Deterministically calculate target rotation + _targetRotation = SpinWheelMath.calculateTargetRotation( + currentRotation: _startRotation, + winningIndex: winningIndex, + count: count, + revolutions: 6, + ); + + _lastPegSectorIndex = -1; + _pointerDeflection = 0.0; + + _spinController.forward(from: 0.0); + } + + void _handleSpinTick() { + final double t = _spinController.value; + final double curvedT = SpinWheelMotionConfig.spinCurve.transform(t); + + final double rotation = _startRotation + (_targetRotation - _startRotation) * curvedT; + final int count = widget.options.length; + + if (count > 0) { + final double sweep = SpinWheelMath.getSweepAngle(count); + // Continuous relative angle + final double normAngle = (rotation % (2 * math.pi)); + final double pegPosition = (normAngle / sweep); + final double pegFraction = pegPosition - pegPosition.floorToDouble(); + + // Check peg passage boundary + final int currentPegSector = pegPosition.floor(); + if (currentPegSector != _lastPegSectorIndex) { + _lastPegSectorIndex = currentPegSector; + // Trigger subtle tick sound and selection haptic + SoundService.triggerHaptic(); + } + + // Pointer deflection: deflects in rotation direction when near peg boundary (fraction < 0.25) + if (pegFraction < 0.25) { + final double factor = math.sin((pegFraction / 0.25) * math.pi); + _pointerDeflection = factor * SpinWheelMotionConfig.pointerMaxDeflection; + } else { + _pointerDeflection = 0.0; + } + } + + setState(() { + _currentRotation = rotation; + }); + } + + void _handleSpinStatus(AnimationStatus status) { + if (status == AnimationStatus.completed) { + // Normalize rotation modulo 2pi for clean continuous future spins + setState(() { + _currentRotation = _targetRotation % (2 * math.pi); + _startRotation = _currentRotation; + _pointerDeflection = 0.0; + }); + HapticFeedback.mediumImpact(); + widget.onSpinCompleted?.call(); + } + } + + @override + void dispose() { + _spinController.dispose(); + _idleController.dispose(); + _pressController.dispose(); + super.dispose(); + } + + void _onTapDown(TapDownDetails details) { + if (widget.isSpinning || _spinController.isAnimating) return; + _pressController.forward(); + HapticFeedback.selectionClick(); + } + + void _onTapUp(TapUpDetails details) { + if (widget.isSpinning || _spinController.isAnimating) return; + _pressController.reverse(); + widget.onSpinRequested?.call(); + } + + void _onTapCancel() { + if (widget.isSpinning || _spinController.isAnimating) return; + _pressController.reverse(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDarkMode = theme.brightness == Brightness.dark; + final reducedMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false; + + final String semanticsLabel = widget.isSpinning + ? 'Spin wheel is spinning' + : widget.targetWinner != null + ? 'Spin wheel winner is ${widget.targetWinner!.label}. Tap to spin again' + : 'Spin wheel ready with ${widget.options.length} options. Tap to spin'; + + return Semantics( + label: semanticsLabel, + button: true, + enabled: !widget.isSpinning, + child: GestureDetector( + onTapDown: _onTapDown, + onTapUp: _onTapUp, + onTapCancel: _onTapCancel, + child: AnimatedBuilder( + animation: Listenable.merge([_spinController, _idleController, _pressController]), + builder: (context, _) { + final double pressT = _pressController.value; + final double spinT = _spinController.value; + + // Physical tactile scale: compression on press, gentle breathing during spin + double scale = 1.0 - (pressT * (1.0 - SpinWheelMotionConfig.pressScale)); + if (!reducedMotion && _spinController.isAnimating) { + if (spinT < SpinWheelMotionConfig.accelerationEnd) { + final subT = spinT / SpinWheelMotionConfig.accelerationEnd; + scale = 1.0 + (subT * 0.02); + } else if (spinT > SpinWheelMotionConfig.decelerationEnd) { + final subT = (spinT - SpinWheelMotionConfig.decelerationEnd) / + (1.0 - SpinWheelMotionConfig.decelerationEnd); + scale = 1.02 - (subT * 0.02); + } else { + scale = 1.02; + } + } + + // Subtle idle breathing scale + if (!reducedMotion && !_spinController.isAnimating && pressT == 0) { + final idlePhase = math.sin(_idleController.value * math.pi * 2); + scale += idlePhase * 0.006; + } + + final double wheelSize = widget.size; + + return Transform.scale( + scale: scale, + child: SizedBox( + width: wheelSize, + height: wheelSize + 24.0, // extra clearance for top pointer & contact shadow + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + // 1. Dynamic Contact Shadow + Positioned( + bottom: 4.0, + child: Container( + width: wheelSize * SpinWheelMotionConfig.shadowIdleScale, + height: wheelSize * 0.14, + decoration: BoxDecoration( + shape: BoxShape.rectangle, + borderRadius: BorderRadius.all(Radius.elliptical(wheelSize * 0.5, wheelSize * 0.07)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues( + alpha: isDarkMode + ? SpinWheelMotionConfig.shadowIdleOpacity * 1.6 + : SpinWheelMotionConfig.shadowIdleOpacity, + ), + blurRadius: _spinController.isAnimating ? 18.0 : 12.0, + spreadRadius: _spinController.isAnimating ? 4.0 : 2.0, + ), + ], + ), + ), + ), + + // 2. Rotating Wheel Body + Positioned( + top: 14.0, + child: Transform.rotate( + angle: _currentRotation, + child: SizedBox( + width: wheelSize, + height: wheelSize, + child: CustomPaint( + painter: WheelPainter( + options: widget.options, + isDarkMode: isDarkMode, + ), + ), + ), + ), + ), + + // 3. Fixed Top Physical Pointer (Deflects on Peg Impact) + Positioned( + top: 4.0, + child: WheelPointer( + deflectionAngle: _pointerDeflection, + isDarkMode: isDarkMode, + size: 30.0, + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } +} + diff --git a/lib/features/spin_wheel/spin_wheel_math.dart b/lib/features/spin_wheel/spin_wheel_math.dart new file mode 100644 index 0000000..714b30b --- /dev/null +++ b/lib/features/spin_wheel/spin_wheel_math.dart @@ -0,0 +1,114 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; + +/// Central mathematical calculation and coordinate system engine for the Spin Wheel. +class SpinWheelMath { + SpinWheelMath._(); + + /// Canonical pointer position is at Top (12 o'clock, -pi / 2 radians). + static const double pointerAngle = -math.pi / 2; + + /// Angular span of a single segment given total segment count [count]. + static double getSweepAngle(int count) { + if (count <= 0) return 2 * math.pi; + return (2 * math.pi) / count; + } + + /// Starting angle for sector [index] on an unrotated wheel. + static double getSectorStartAngle(int index, int count) { + return pointerAngle + (index * getSweepAngle(count)); + } + + /// Center angle for sector [index] on an unrotated wheel. + static double getSectorCenterAngle(int index, int count) { + return pointerAngle + ((index + 0.5) * getSweepAngle(count)); + } + + /// Deterministically calculates the target rotation angle to align sector [winningIndex] directly under the pointer. + /// + /// [currentRotation] is the continuous rotation angle before the spin. + /// [winningIndex] is the predetermined winning segment index (0 <= winningIndex < count). + /// [count] is the total number of segments. + /// [revolutions] is the number of complete 360° revolutions to perform. + static double calculateTargetRotation({ + required double currentRotation, + required int winningIndex, + required int count, + int revolutions = 6, + }) { + final double sweep = getSweepAngle(count); + + // To bring sector center ((index + 0.5) * sweep) to the pointer: + // centerAngle + finalRotation = pointerAngle (mod 2pi) + // finalRotation = - (winningIndex + 0.5) * sweep (mod 2pi) + final double targetBase = (2 * math.pi - ((winningIndex + 0.5) * sweep) % (2 * math.pi)) % (2 * math.pi); + + final double normalizedCurrent = currentRotation % (2 * math.pi); + double delta = targetBase - normalizedCurrent; + if (delta <= 0.00001) { + delta += 2 * math.pi; + } + + return currentRotation + (revolutions * 2 * math.pi) + delta; + } + + /// Calculates the segment index currently aligned under the pointer at a given rotation [rotation]. + static int getIndexUnderPointer({ + required double rotation, + required int count, + }) { + if (count <= 0) return 0; + final double sweep = getSweepAngle(count); + // Effective angle on the unrotated wheel that corresponds to pointerAngle + final double effectiveAngle = (2 * math.pi - (rotation % (2 * math.pi))) % (2 * math.pi); + final int index = (effectiveAngle / sweep).floor() % count; + return index; + } + + // --- Curated Premium Palettes --- + + /// Harmonious game-night segment palette matching PlayMate's warm ivory/charcoal/bronze design tokens. + static const List lightSegmentPalette = [ + Color(0xFFF3F0E8), // Warm Ivory + Color(0xFF2B2C31), // Deep Charcoal + Color(0xFFE4DFD3), // Muted Sand + Color(0xFF383A42), // Slate Graphite + Color(0xFFD6CEBD), // Soft Khaki + Color(0xFFC18A42), // Warm Bronze Accent + Color(0xFFECE7DC), // Pearl Ivory + Color(0xFF484B54), // Dark Slate + ]; + + static const List darkSegmentPalette = [ + Color(0xFF25272C), // Matte Graphite + Color(0xFF1B1C20), // Deep Obsidian + Color(0xFF33353D), // Muted Dark Slate + Color(0xFF2A2C33), // Dark Neutral + Color(0xFF3D3F49), // Charcoal Slate + Color(0xFFC18A42), // Warm Bronze Accent + Color(0xFF2E3038), // Carbon Grey + Color(0xFF18191D), // Rich Black + ]; + + /// Resolves the background color for sector [index] in a wheel of [count] options. + static Color getSegmentColor(int index, int count, bool isDarkMode) { + final palette = isDarkMode ? darkSegmentPalette : lightSegmentPalette; + // For odd segment counts like 3 or 5, ensure adjacent segments and first/last don't clash + if (count % 2 != 0 && index == count - 1) { + return isDarkMode ? const Color(0xFFC18A42) : const Color(0xFFC18A42); + } + return palette[index % palette.length]; + } + + /// Resolves optimal readable text color against segment background. + static Color getTextColorForSegment(Color segmentColor, bool isDarkMode) { + final double luminance = segmentColor.computeLuminance(); + // High contrast threshold + if (luminance > 0.45) { + return const Color(0xFF17181A); // Deep Charcoal + } else { + return const Color(0xFFF5F5F2); // Warm Pearl + } + } +} + diff --git a/lib/features/spin_wheel/spin_wheel_models.dart b/lib/features/spin_wheel/spin_wheel_models.dart new file mode 100644 index 0000000..6d1f084 --- /dev/null +++ b/lib/features/spin_wheel/spin_wheel_models.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; + +/// Represents an individual option on the PlayMate Spin Wheel with a stable identity. +class WheelOption { + final String id; + final String label; + final Color? customColor; + + const WheelOption({ + required this.id, + required this.label, + this.customColor, + }); + + WheelOption copyWith({ + String? id, + String? label, + Color? customColor, + }) { + return WheelOption( + id: id ?? this.id, + label: label ?? this.label, + customColor: customColor ?? this.customColor, + ); + } + + Map toJson() => { + 'id': id, + 'label': label, + if (customColor != null) 'color': customColor!.toARGB32(), + }; + + factory WheelOption.fromJson(Map json) { + return WheelOption( + id: json['id'] as String, + label: json['label'] as String, + customColor: json['color'] != null ? Color(json['color'] as int) : null, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is WheelOption && + runtimeType == other.runtimeType && + id == other.id && + label == other.label; + + @override + int get hashCode => id.hashCode ^ label.hashCode; +} + +/// A preconfigured wheel collection for instant game-night utility. +class SpinWheelPreset { + final String id; + final String name; + final List optionLabels; + + const SpinWheelPreset({ + required this.id, + required this.name, + required this.optionLabels, + }); + + List toOptions() { + return optionLabels.asMap().entries.map((entry) { + return WheelOption( + id: '${id}_${entry.key}_${DateTime.now().microsecondsSinceEpoch}', + label: entry.value, + ); + }).toList(); + } + + static const List builtInPresets = [ + SpinWheelPreset( + id: 'truth_or_dare', + name: 'Truth or Dare', + optionLabels: ['Truth', 'Dare', 'Pass', 'Double Dare'], + ), + SpinWheelPreset( + id: 'decisions', + name: 'Quick Decisions', + optionLabels: ['Yes', 'No', 'Maybe', 'Ask Again'], + ), + SpinWheelPreset( + id: 'food_picker', + name: 'Food Picker', + optionLabels: ['Pizza', 'Burgers', 'Sushi', 'Tacos', 'Pasta', 'Salad'], + ), + SpinWheelPreset( + id: 'dice_numbers', + name: 'D6 Roll Wheel', + optionLabels: ['1', '2', '3', '4', '5', '6'], + ), + SpinWheelPreset( + id: 'teams', + name: 'Team Selector', + optionLabels: ['Team Red', 'Team Blue', 'Team Green', 'Team Gold'], + ), + ]; +} + +/// Recorded historical outcome of a spin. +class SpinWheelRecord { + final String winnerLabel; + final DateTime timestamp; + + const SpinWheelRecord({ + required this.winnerLabel, + required this.timestamp, + }); + + Map toJson() => { + 'winner': winnerLabel, + 'time': timestamp.millisecondsSinceEpoch, + }; + + factory SpinWheelRecord.fromJson(Map json) { + return SpinWheelRecord( + winnerLabel: json['winner'] as String, + timestamp: DateTime.fromMillisecondsSinceEpoch(json['time'] as int), + ); + } +} + diff --git a/lib/features/spin_wheel/spin_wheel_motion_config.dart b/lib/features/spin_wheel/spin_wheel_motion_config.dart new file mode 100644 index 0000000..eebc7bc --- /dev/null +++ b/lib/features/spin_wheel/spin_wheel_motion_config.dart @@ -0,0 +1,60 @@ +import 'package:flutter/animation.dart'; + +/// Centralized motion configuration and physical timing layer for the Spin Wheel. +class SpinWheelMotionConfig { + SpinWheelMotionConfig._(); + + // --- Animation Durations --- + /// Total standard spin sequence duration (~3800ms) + static const Duration spinDuration = Duration(milliseconds: 3800); + + /// Reduced motion quick transition duration (~400ms) + static const Duration reducedMotionDuration = Duration(milliseconds: 400); + + /// Press interaction spring back duration + static const Duration pressDuration = Duration(milliseconds: 100); + + /// Idle breathing cycle duration + static const Duration idleCycleDuration = Duration(milliseconds: 3200); + + // --- Normalized Phase Milestones [0.0, 1.0] --- + /// Anticipation compression: 0 to ~120ms (0.0 to 0.032) + static const double anticipationEnd = 0.032; + + /// Acceleration: 120ms to 850ms (0.032 to 0.224) + static const double accelerationEnd = 0.224; + + /// Sustained high speed spin: 850ms to 2400ms (0.224 to 0.632) + static const double sustainedEnd = 0.632; + + /// Deceleration: 2400ms to 3550ms (0.632 to 0.934) + static const double decelerationEnd = 0.934; + + /// Settle and micro bounce: 3550ms to 3800ms (0.934 to 1.0) + static const double settleEnd = 1.0; + + // --- Spatial Scales & Deflections --- + /// Button/wheel scale when pressed down + static const double pressScale = 0.97; + + /// Scale during anticipation wind-up + static const double anticipationScale = 0.985; + + /// Scale during settle micro-bounce + static const double settleScaleMax = 1.018; + + /// Maximum deflection angle of the pointer in radians (~0.08 rad = ~4.5 degrees) + static const double pointerMaxDeflection = 0.08; + + // --- Shadow Parameters --- + static const double shadowIdleScale = 1.0; + static const double shadowIdleOpacity = 0.25; + static const double shadowSpinScale = 1.04; + static const double shadowSpinOpacity = 0.32; + + // --- Animation Curves --- + /// Smooth physical spin curve: natural build up followed by gradual bearing friction deceleration + static const Curve spinCurve = Curves.easeOutCubic; + static const Curve reducedMotionCurve = Curves.easeOutQuad; +} + diff --git a/lib/features/spin_wheel/spin_wheel_provider.dart b/lib/features/spin_wheel/spin_wheel_provider.dart index 9c733d1..cb91e2c 100644 --- a/lib/features/spin_wheel/spin_wheel_provider.dart +++ b/lib/features/spin_wheel/spin_wheel_provider.dart @@ -1,83 +1,265 @@ import 'dart:math'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../services/firebase_service.dart'; -import '../../services/sound_service.dart'; +import '../../services/storage_service.dart'; +import 'spin_wheel_models.dart'; +import 'spin_wheel_motion_config.dart'; class SpinWheelState { - final List options; - final String? selectedResult; + final List options; + final WheelOption? selectedWinner; final bool isSpinning; + final List history; + final String activePresetId; - SpinWheelState({ + const SpinWheelState({ required this.options, - this.selectedResult, + this.selectedWinner, required this.isSpinning, + this.history = const [], + this.activePresetId = 'truth_or_dare', }); SpinWheelState copyWith({ - List? options, - String? selectedResult, + List? options, + WheelOption? selectedWinner, + bool clearWinner = false, bool? isSpinning, + List? history, + String? activePresetId, }) { return SpinWheelState( options: options ?? this.options, - selectedResult: selectedResult ?? this.selectedResult, + selectedWinner: clearWinner ? null : (selectedWinner ?? this.selectedWinner), isSpinning: isSpinning ?? this.isSpinning, + history: history ?? this.history, + activePresetId: activePresetId ?? this.activePresetId, ); } + + /// Convenience getter for backward compatibility + String? get selectedResult => selectedWinner?.label; + + /// Total spins recorded + int get totalSpins => history.length; } class SpinWheelNotifier extends StateNotifier { final Random _random = Random(); - SpinWheelNotifier() : super(SpinWheelState( - options: ['Truth', 'Dare', 'Pass', 'Double Dare'], - isSpinning: false, - )); + SpinWheelNotifier() + : super(SpinWheelState( + options: SpinWheelPreset.builtInPresets.first.toOptions(), + isSpinning: false, + history: const [], + activePresetId: 'truth_or_dare', + )) { + _loadPersistedData(); + } + + void _loadPersistedData() { + if (!StorageService.isBoxOpen(StorageService.statsBoxName)) return; + final box = StorageService.getStatsBox(); + + // 1. Load custom options if saved + final List? savedOptionsRaw = box.get('spin_wheel_options'); + List? loadedOptions; + if (savedOptionsRaw != null && savedOptionsRaw.isNotEmpty) { + try { + loadedOptions = savedOptionsRaw + .map((e) => WheelOption.fromJson(Map.from(e as Map))) + .toList(); + } catch (_) { + loadedOptions = null; + } + } + + // 2. Load history + final List? savedHistoryRaw = box.get('spin_wheel_history'); + List loadedHistory = []; + if (savedHistoryRaw != null) { + try { + loadedHistory = savedHistoryRaw + .map((e) => SpinWheelRecord.fromJson(Map.from(e as Map))) + .toList(); + } catch (_) { + loadedHistory = []; + } + } + + final String savedPresetId = + box.get('spin_wheel_active_preset', defaultValue: 'truth_or_dare') as String; + + if (loadedOptions != null && loadedOptions.length >= 2) { + state = state.copyWith( + options: loadedOptions, + history: loadedHistory, + activePresetId: savedPresetId, + ); + } else { + state = state.copyWith( + history: loadedHistory, + activePresetId: savedPresetId, + ); + } + } + + Future _persistOptions() async { + if (!mounted || !StorageService.isBoxOpen(StorageService.statsBoxName)) return; + final box = StorageService.getStatsBox(); + await box.put( + 'spin_wheel_options', + state.options.map((o) => o.toJson()).toList(), + ); + if (!mounted) return; + await box.put('spin_wheel_active_preset', state.activePresetId); + } - void addOption(String option) { - final trimmed = option.trim(); - if (trimmed.isEmpty || state.options.contains(trimmed)) return; - state = state.copyWith(options: [...state.options, trimmed]); + Future _persistHistory() async { + if (!mounted || !StorageService.isBoxOpen(StorageService.statsBoxName)) return; + final box = StorageService.getStatsBox(); + await box.put( + 'spin_wheel_history', + state.history.map((r) => r.toJson()).toList(), + ); + final totalSpins = box.get('total_wheel_spins', defaultValue: 0) as int; + await box.put('total_wheel_spins', totalSpins + 1); } - void removeOption(String option) { - if (state.options.length <= 2) return; // Need at least 2 options to spin + /// Adds a new option to the wheel. Minimum 2 options constraint is respected. + void addOption(String label) { + final trimmed = label.trim(); + if (trimmed.isEmpty) return; + if (state.options.length >= 16) return; // Prevent unreasonable segment squeeze + + final newOption = WheelOption( + id: 'opt_${DateTime.now().microsecondsSinceEpoch}', + label: trimmed, + ); + state = state.copyWith( - options: List.from(state.options)..remove(option), - selectedResult: null, + options: [...state.options, newOption], + activePresetId: 'custom', ); + _persistOptions(); } - Future spin() async { - if (state.isSpinning || state.options.isEmpty) return; - state = state.copyWith(isSpinning: true, selectedResult: null); + /// Updates the label of an existing option by [id]. + void updateOption(String id, String newLabel) { + final trimmed = newLabel.trim(); + if (trimmed.isEmpty) return; + + final updated = state.options.map((opt) { + if (opt.id == id) { + return opt.copyWith(label: trimmed); + } + return opt; + }).toList(); + + state = state.copyWith( + options: updated, + activePresetId: 'custom', + ); + _persistOptions(); + } + + /// Removes an option by [id]. Wheel requires at least 2 options to operate. + void removeOptionById(String id) { + if (state.options.length <= 2) return; - // Dynamic tick simulation - for (int i = 0; i < 10; i++) { - await Future.delayed(Duration(milliseconds: 100 + (i * 20))); - SoundService.playRollSound(); + final updated = state.options.where((opt) => opt.id != id).toList(); + state = state.copyWith( + options: updated, + clearWinner: state.selectedWinner?.id == id, + activePresetId: 'custom', + ); + _persistOptions(); + } + + /// Removes the currently selected winner from the wheel (game-night elimination mechanic). + void removeWinner() { + if (state.selectedWinner == null || state.options.length <= 2) return; + removeOptionById(state.selectedWinner!.id); + } + + /// For backward compatibility with existing string removal + void removeOption(String label) { + final match = state.options.where((opt) => opt.label == label).toList(); + if (match.isNotEmpty) { + removeOptionById(match.first.id); } + } + + /// Loads a predefined game-night preset. + void applyPreset(SpinWheelPreset preset) { + if (state.isSpinning) return; + state = state.copyWith( + options: preset.toOptions(), + clearWinner: true, + activePresetId: preset.id, + ); + _persistOptions(); + } + + /// Initiates an authoritative, deterministic spin sequence. + /// + /// Flow: + /// 1. Logical random winner is chosen immediately. + /// 2. State published with `selectedWinner` and `isSpinning: true`. + /// 3. Physical wheel widget animates decelerating smoothly onto that exact sector. + /// 4. After timeline completes, history and statistics are committed, `isSpinning: false`. + Future spin() async { + if (state.isSpinning || state.options.length < 2) return; - final selected = state.options[_random.nextInt(state.options.length)]; - SoundService.triggerHaptic(); + // 1. Authoritative logical outcome picked first + final int winnerIndex = _random.nextInt(state.options.length); + final WheelOption chosenWinner = state.options[winnerIndex]; + + // 2. Publish target winner immediately so the renderer targets this sector + state = state.copyWith( + isSpinning: true, + selectedWinner: chosenWinner, + ); + + // 3. Await duration matching the physical spin timeline + await Future.delayed(SpinWheelMotionConfig.spinDuration); + + // 4. Record to history upon settling + final record = SpinWheelRecord( + winnerLabel: chosenWinner.label, + timestamp: DateTime.now(), + ); + + final updatedHistory = List.from(state.history)..insert(0, record); + if (updatedHistory.length > 50) { + updatedHistory.removeLast(); + } state = state.copyWith( isSpinning: false, - selectedResult: selected, + selectedWinner: chosenWinner, + history: updatedHistory, ); + await _persistHistory(); AnalyticsService.trackSpinWheelUsed(); } + /// Clears historical outcomes + void clearHistory() { + state = state.copyWith(history: const []); + if (StorageService.isBoxOpen(StorageService.statsBoxName)) { + StorageService.getStatsBox().delete('spin_wheel_history'); + } + } + + /// Resets to default preset void resetOptions() { - state = SpinWheelState( - options: ['Truth', 'Dare', 'Pass', 'Double Dare'], - isSpinning: false, - ); + applyPreset(SpinWheelPreset.builtInPresets.first); } } -final spinWheelProvider = StateNotifierProvider((ref) { +final spinWheelProvider = + StateNotifierProvider((ref) { return SpinWheelNotifier(); }); diff --git a/lib/features/spin_wheel/spin_wheel_screen.dart b/lib/features/spin_wheel/spin_wheel_screen.dart index 77aef3c..daabd20 100644 --- a/lib/features/spin_wheel/spin_wheel_screen.dart +++ b/lib/features/spin_wheel/spin_wheel_screen.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'dart:math'; + import '../../shared/responsive_layout.dart'; +import 'playmate_spin_wheel.dart'; +import 'spin_wheel_models.dart'; import 'spin_wheel_provider.dart'; class SpinWheelScreen extends ConsumerStatefulWidget { @@ -11,60 +13,270 @@ class SpinWheelScreen extends ConsumerStatefulWidget { ConsumerState createState() => _SpinWheelScreenState(); } -class _SpinWheelScreenState extends ConsumerState with SingleTickerProviderStateMixin { - late AnimationController _rotationController; - late Animation _animation; +class _SpinWheelScreenState extends ConsumerState { final TextEditingController _optionController = TextEditingController(); @override - void initState() { - super.initState(); - _rotationController = AnimationController( - vsync: this, - duration: const Duration(seconds: 3), - ); - - _animation = CurvedAnimation( - parent: _rotationController, - curve: Curves.decelerate, - ); + void dispose() { + _optionController.dispose(); + super.dispose(); } - void _spin() { - final notifier = ref.read(spinWheelProvider.notifier); + void _triggerSpin() { final wheelState = ref.read(spinWheelProvider); - if (wheelState.isSpinning) return; - _rotationController.forward(from: 0.0); - notifier.spin(); + if (wheelState.isSpinning || wheelState.options.length < 2) return; + ref.read(spinWheelProvider.notifier).spin(); } - void _addOption() { - if (_optionController.text.isNotEmpty) { - ref.read(spinWheelProvider.notifier).addOption(_optionController.text); - _optionController.clear(); - } + void _showAddOptionDialog(BuildContext context) { + _optionController.clear(); + showDialog( + context: context, + builder: (ctx) { + return AlertDialog( + title: const Text('Add Wheel Option'), + content: TextField( + controller: _optionController, + autofocus: true, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + hintText: 'Enter option label', + border: OutlineInputBorder(), + ), + onSubmitted: (val) { + if (val.trim().isNotEmpty) { + ref.read(spinWheelProvider.notifier).addOption(val); + Navigator.pop(ctx); + } + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('CANCEL'), + ), + FilledButton( + onPressed: () { + if (_optionController.text.trim().isNotEmpty) { + ref.read(spinWheelProvider.notifier).addOption(_optionController.text); + Navigator.pop(ctx); + } + }, + child: const Text('ADD'), + ), + ], + ); + }, + ); } - @override - void dispose() { - _rotationController.dispose(); - _optionController.dispose(); - super.dispose(); + void _showPresetsBottomSheet(BuildContext context) { + final activePreset = ref.read(spinWheelProvider).activePresetId; + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.m), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.m), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Wheel Presets', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + ), + const Divider(), + ...SpinWheelPreset.builtInPresets.map((preset) { + final isSelected = activePreset == preset.id; + return ListTile( + title: Text( + preset.name, + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, + color: isSelected ? const Color(0xFFC18A42) : null, + ), + ), + subtitle: Text( + preset.optionLabels.join(', '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ), + trailing: isSelected + ? const Icon(Icons.check_circle, color: Color(0xFFC18A42)) + : null, + onTap: () { + ref.read(spinWheelProvider.notifier).applyPreset(preset); + Navigator.pop(ctx); + }, + ); + }), + ], + ), + ), + ); + }, + ); + } + + void _showEditOptionsSheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) { + return Consumer( + builder: (context, ref, _) { + final wheelState = ref.watch(spinWheelProvider); + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.65, + maxChildSize: 0.9, + minChildSize: 0.4, + builder: (_, scrollController) { + return SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Edit Options (${wheelState.options.length})', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + Row( + children: [ + TextButton.icon( + onPressed: () => _showAddOptionDialog(context), + icon: const Icon(Icons.add, size: 18), + label: const Text('Add'), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: ListView.builder( + controller: scrollController, + itemCount: wheelState.options.length, + itemBuilder: (context, index) { + final option = wheelState.options[index]; + final canDelete = wheelState.options.length > 2; + return ListTile( + leading: CircleAvatar( + radius: 14, + backgroundColor: const Color(0xFFC18A42).withValues(alpha: 0.2), + child: Text( + '${index + 1}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Color(0xFFC18A42), + ), + ), + ), + title: Text(option.label), + trailing: IconButton( + icon: Icon( + Icons.delete_outline, + color: canDelete ? Colors.red.shade400 : Colors.grey, + ), + onPressed: canDelete + ? () => ref + .read(spinWheelProvider.notifier) + .removeOptionById(option.id) + : null, + tooltip: canDelete ? 'Delete option' : 'Minimum 2 options required', + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + }, + ); + }, + ); } @override Widget build(BuildContext context) { final wheelState = ref.watch(spinWheelProvider); final theme = Theme.of(context); + final isDarkMode = theme.brightness == Brightness.dark; return Scaffold( appBar: AppBar( title: const Text('Spin Wheel'), actions: [ IconButton( - icon: const Icon(Icons.refresh), - onPressed: () => ref.read(spinWheelProvider.notifier).resetOptions(), - tooltip: 'Reset Options', + icon: const Icon(Icons.list_alt), + onPressed: wheelState.isSpinning ? null : () => _showEditOptionsSheet(context), + tooltip: 'Options', + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + enabled: !wheelState.isSpinning, + onSelected: (val) { + if (val == 'presets') { + _showPresetsBottomSheet(context); + } else if (val == 'reset') { + ref.read(spinWheelProvider.notifier).resetOptions(); + } + }, + itemBuilder: (ctx) => [ + const PopupMenuItem( + value: 'presets', + child: Row( + children: [ + Icon(Icons.palette_outlined, size: 18), + SizedBox(width: 8), + Text('Presets'), + ], + ), + ), + const PopupMenuItem( + value: 'reset', + child: Row( + children: [ + Icon(Icons.refresh, size: 18), + SizedBox(width: 8), + Text('Reset to Default'), + ], + ), + ), + ], ), ], ), @@ -72,181 +284,432 @@ class _SpinWheelScreenState extends ConsumerState with SingleTi child: ResponsiveLayout( mobile: Column( children: [ + // Hero Wheel & Result Container Expanded( - flex: 4, - child: Center( - child: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(height: AppSpacing.m), - Stack( - alignment: Alignment.center, - children: [ - AnimatedBuilder( - animation: _animation, - builder: (context, child) { - return Transform.rotate( - angle: _animation.value * 2 * pi * 4, - child: Container( - width: 260, - height: 260, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: theme.colorScheme.primary, width: 4), - ), - child: CustomPaint( - painter: WheelPainter(wheelState.options, theme), - ), - ), - ); - }, - ), - // Wheel Pointer - Positioned( - top: 0, - child: Icon( - Icons.arrow_drop_down, - size: 48, - color: theme.colorScheme.error, - ), - ), - ], - ), - const SizedBox(height: AppSpacing.l), - Text( - wheelState.isSpinning - ? 'Spinning...' - : (wheelState.selectedResult ?? 'Ready to Spin!'), - style: theme.textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.m, + vertical: AppSpacing.s, ), - ), - ), - const Divider(), - Expanded( - flex: 3, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(AppSpacing.m), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _optionController, - decoration: const InputDecoration( - hintText: 'Add custom option', - border: OutlineInputBorder(), - ), - onSubmitted: (_) => _addOption(), - ), - ), - const SizedBox(width: AppSpacing.s), - IconButton.filled( - onPressed: _addOption, - icon: const Icon(Icons.add), - ), - ], - ), - ), - Expanded( - child: ListView.builder( - itemCount: wheelState.options.length, - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.m), - itemBuilder: (context, index) { - final option = wheelState.options[index]; - return ListTile( - title: Text(option), - trailing: IconButton( - icon: const Icon(Icons.close), - onPressed: () => ref.read(spinWheelProvider.notifier).removeOption(option), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Active Preset Banner / Options Badge + _buildPresetIndicator(wheelState, theme, isDarkMode), + + const SizedBox(height: 12), + + // Responsive Hero Physical Spin Wheel + LayoutBuilder( + builder: (context, constraints) { + final double wheelSize = _calculateResponsiveWheelSize(constraints.maxWidth); + return Center( + child: PlayMateSpinWheel( + options: wheelState.options, + targetWinner: wheelState.selectedWinner, + isSpinning: wheelState.isSpinning, + size: wheelSize, + onSpinRequested: _triggerSpin, ), ); }, ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.all(AppSpacing.m), - child: SizedBox( - width: double.infinity, - height: 50, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.primary, - foregroundColor: theme.colorScheme.onPrimary, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - onPressed: _spin, - child: const Text('SPIN THE WHEEL', style: TextStyle(fontWeight: FontWeight.bold)), + + const SizedBox(height: 16), + + // Winner / Result announcement card + _buildResultSection(wheelState, theme, isDarkMode), + + const SizedBox(height: 14), + + // Compact Recent History + if (wheelState.history.isNotEmpty) ...[ + _buildRecentHistorySection(wheelState, theme, isDarkMode), + ], + ], ), ), ), + + // Bottom Control Bar + _buildBottomControls(wheelState, theme), ], ), ), ), ); } -} -class WheelPainter extends CustomPainter { - final List options; - final ThemeData theme; + double _calculateResponsiveWheelSize(double availableWidth) { + if (availableWidth <= 330) { + return 230.0; + } else if (availableWidth <= 440) { + return 265.0; + } else if (availableWidth <= 600) { + return 290.0; + } else { + return 330.0; + } + } - WheelPainter(this.options, this.theme); + Widget _buildPresetIndicator(SpinWheelState wheelState, ThemeData theme, bool isDarkMode) { + final presetName = SpinWheelPreset.builtInPresets + .firstWhere( + (p) => p.id == wheelState.activePresetId, + orElse: () => const SpinWheelPreset( + id: 'custom', + name: 'Custom Wheel', + optionLabels: [], + ), + ) + .name; - @override - void paint(Canvas canvas, Size size) { - final double radius = size.width / 2; - final Rect rect = Rect.fromCircle(center: Offset(radius, radius), radius: radius); - final double sweepAngle = 2 * pi / options.length; - - final List colors = [ - theme.colorScheme.primary.withValues(alpha: 0.05), - theme.colorScheme.primary.withValues(alpha: 0.15), - theme.colorScheme.primary.withValues(alpha: 0.25), - theme.colorScheme.primary.withValues(alpha: 0.35), - ]; - - for (int i = 0; i < options.length; i++) { - final Paint paint = Paint() - ..color = colors[i % colors.length] - ..style = PaintingStyle.fill; - - canvas.drawArc(rect, i * sweepAngle, sweepAngle, true, paint); - - // Draw text option inside sector - final double textAngle = i * sweepAngle + sweepAngle / 2; - canvas.save(); - canvas.translate(radius, radius); - canvas.rotate(textAngle); - - final textSpan = TextSpan( - text: options[i].length > 10 ? '${options[i].substring(0, 8)}..' : options[i], - style: TextStyle( - color: theme.colorScheme.onSurface, - fontWeight: FontWeight.bold, - fontSize: 12, + return Wrap( + alignment: WrapAlignment.center, + spacing: 8.0, + runSpacing: 6.0, + children: [ + InkWell( + onTap: wheelState.isSpinning ? null : () => _showPresetsBottomSheet(context), + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isDarkMode ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFFC18A42).withValues(alpha: 0.35), + width: 1.0, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.tune, size: 14, color: Color(0xFFC18A42)), + const SizedBox(width: 6), + Text( + presetName, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + const SizedBox(width: 4), + const Icon(Icons.arrow_drop_down, size: 16, color: Color(0xFFC18A42)), + ], + ), + ), + ), + InkWell( + onTap: wheelState.isSpinning ? null : () => _showEditOptionsSheet(context), + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: isDarkMode ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isDarkMode ? const Color(0xFF2C2C2C) : const Color(0xFFE0E0E0), + width: 1.0, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.pie_chart_outline, size: 13, color: theme.colorScheme.secondary), + const SizedBox(width: 5), + Text( + '${wheelState.options.length} options', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: theme.colorScheme.secondary, + ), + ), + ], + ), + ), ), + ], + ); + } + + Widget _buildResultSection( + SpinWheelState wheelState, + ThemeData theme, + bool isDarkMode, + ) { + if (wheelState.isSpinning) { + return Column( + children: [ + Text( + 'Spinning...', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 4), + Text( + 'Watching the pointer tick past pegs', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.secondary, + ), + ), + ], ); - final textPainter = TextPainter( - text: textSpan, - textDirection: TextDirection.ltr, + } + + final winner = wheelState.selectedWinner; + + if (winner == null) { + return Column( + children: [ + Text( + 'Ready to Spin!', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 4), + Text( + 'Tap the wheel or button to spin', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.secondary, + ), + ), + ], ); - textPainter.layout(); - textPainter.paint(canvas, Offset(radius * 0.4, -textPainter.height / 2)); - canvas.restore(); } + + final canRemoveWinner = wheelState.options.length > 2; + + return Semantics( + label: 'Winner selected: ${winner.label}', + child: Container( + width: double.infinity, + constraints: const BoxConstraints(maxWidth: 400), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: isDarkMode ? const Color(0xFF1E1E1E) : const Color(0xFFF7F7F7), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFFC18A42).withValues(alpha: 0.4), + width: 1.2, + ), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.star, size: 16, color: Color(0xFFC18A42)), + const SizedBox(width: 6), + Text( + 'WINNER SELECTED', + style: theme.textTheme.labelSmall?.copyWith( + color: const Color(0xFFC18A42), + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + ), + ), + const SizedBox(width: 6), + const Icon(Icons.star, size: 16, color: Color(0xFFC18A42)), + ], + ), + const SizedBox(height: 6), + Text( + winner.label, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.headlineLarge?.copyWith( + fontSize: 30, + fontWeight: FontWeight.w900, + letterSpacing: -0.5, + height: 1.15, + ), + ), + const SizedBox(height: 10), + Wrap( + alignment: WrapAlignment.center, + spacing: 8.0, + runSpacing: 8.0, + children: [ + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + side: BorderSide( + color: isDarkMode ? const Color(0xFF383838) : const Color(0xFFD0D0D0), + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + onPressed: _triggerSpin, + icon: const Icon(Icons.replay, size: 16), + label: const Text('SPIN AGAIN', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), + ), + if (canRemoveWinner) + FilledButton.tonalIcon( + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + onPressed: () => ref.read(spinWheelProvider.notifier).removeWinner(), + icon: const Icon(Icons.remove_circle_outline, size: 16), + label: const Text('REMOVE WINNER', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), + ), + ], + ), + ], + ), + ), + ); } - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => true; + Widget _buildRecentHistorySection( + SpinWheelState wheelState, + ThemeData theme, + bool isDarkMode, + ) { + final recent = wheelState.history.take(8).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.history, size: 15, color: theme.colorScheme.secondary), + const SizedBox(width: 6), + Text( + 'Recent Outcomes', + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.secondary, + letterSpacing: 0.5, + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.delete_outline, size: 16), + visualDensity: VisualDensity.compact, + tooltip: 'Clear history', + onPressed: () => ref.read(spinWheelProvider.notifier).clearHistory(), + ), + ], + ), + const SizedBox(height: 6), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: recent.asMap().entries.map((entry) { + final index = entry.key; + final record = entry.value; + final isLatest = index == 0; + + return Padding( + padding: const EdgeInsets.only(right: 6.0), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: isLatest + ? (isDarkMode ? const Color(0xFF2C2C2E) : const Color(0xFFEBEBEB)) + : (isDarkMode ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5)), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isLatest + ? const Color(0xFFC18A42).withValues(alpha: 0.4) + : (isDarkMode ? const Color(0xFF2C2C2C) : const Color(0xFFE0E0E0)), + width: 1.0, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isLatest + ? const Color(0xFFC18A42) + : (isDarkMode ? Colors.white60 : Colors.black54), + ), + ), + const SizedBox(width: 6), + Text( + record.winnerLabel, + style: TextStyle( + fontSize: 12, + fontWeight: isLatest ? FontWeight.bold : FontWeight.w600, + color: isLatest + ? (isDarkMode ? Colors.white : const Color(0xFF1A1A1A)) + : theme.colorScheme.secondary, + ), + ), + ], + ), + ), + ); + }).toList(), + ), + ), + ], + ); + } + + Widget _buildBottomControls(SpinWheelState wheelState, ThemeData theme) { + return Container( + padding: const EdgeInsets.all(AppSpacing.m), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + top: BorderSide(color: theme.dividerColor, width: 1), + ), + ), + child: SafeArea( + top: false, + child: SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + disabledBackgroundColor: theme.colorScheme.primary.withValues(alpha: 0.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 0, + ), + onPressed: (wheelState.isSpinning || wheelState.options.length < 2) + ? null + : _triggerSpin, + child: wheelState.isSpinning + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white70, + ), + ) + : const Text( + 'SPIN WHEEL', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + letterSpacing: 1.0, + ), + ), + ), + ), + ), + ); + } } diff --git a/lib/features/spin_wheel/wheel_painter.dart b/lib/features/spin_wheel/wheel_painter.dart new file mode 100644 index 0000000..267103c --- /dev/null +++ b/lib/features/spin_wheel/wheel_painter.dart @@ -0,0 +1,275 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; + +import 'spin_wheel_math.dart'; +import 'spin_wheel_models.dart'; + +/// Renders the physical segmented PlayMate wheel with outer metallic rim, perimeter pegs, +/// radial labels, and central embossed hub. +class WheelPainter extends CustomPainter { + final List options; + final bool isDarkMode; + + const WheelPainter({ + required this.options, + required this.isDarkMode, + }); + + @override + void paint(Canvas canvas, Size size) { + if (options.isEmpty) return; + + final double radius = size.width / 2; + final Offset center = Offset(radius, radius); + final int count = options.length; + final double sweepAngle = SpinWheelMath.getSweepAngle(count); + + // Rim dimensions + final double rimThickness = radius * 0.065; + final double innerRadius = radius - rimThickness; + final Rect innerRect = Rect.fromCircle(center: center, radius: innerRadius); + + // 1. Draw Sector Bodies + for (int i = 0; i < count; i++) { + final double startAngle = SpinWheelMath.getSectorStartAngle(i, count); + final Color sectorColor = options[i].customColor ?? + SpinWheelMath.getSegmentColor(i, count, isDarkMode); + + final Paint sectorPaint = Paint() + ..color = sectorColor + ..style = PaintingStyle.fill; + + canvas.drawArc(innerRect, startAngle, sweepAngle, true, sectorPaint); + } + + // 2. Draw Sector Divider Lines + final Paint dividerPaint = Paint() + ..color = isDarkMode + ? Colors.white.withValues(alpha: 0.18) + : Colors.black.withValues(alpha: 0.15) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2; + + for (int i = 0; i < count; i++) { + final double startAngle = SpinWheelMath.getSectorStartAngle(i, count); + final double dx = center.dx + innerRadius * math.cos(startAngle); + final double dy = center.dy + innerRadius * math.sin(startAngle); + canvas.drawLine(center, Offset(dx, dy), dividerPaint); + } + + // 3. Draw Radial Segment Labels + _drawLabels(canvas, center, innerRadius, count, sweepAngle); + + // 4. Draw Outer Metallic Rim with Chamfer Bevel + _drawOuterRim(canvas, center, radius, innerRadius); + + // 5. Draw Perimeter Pegs / Studs at Sector Boundaries + _drawPerimeterPegs(canvas, center, radius, innerRadius, count); + + // 6. Draw Center Metallic Hub + _drawCenterHub(canvas, center, radius); + } + + void _drawLabels( + Canvas canvas, + Offset center, + double innerRadius, + int count, + double sweepAngle, + ) { + // Dynamic typography scaling based on segment density + final double fontSize = count <= 4 + ? 14.0 + : count <= 8 + ? 12.0 + : count <= 12 + ? 10.5 + : 9.0; + + final double maxLabelWidth = innerRadius * 0.48; + + for (int i = 0; i < count; i++) { + final double centerAngle = SpinWheelMath.getSectorCenterAngle(i, count); + final Color sectorColor = options[i].customColor ?? + SpinWheelMath.getSegmentColor(i, count, isDarkMode); + final Color textColor = SpinWheelMath.getTextColorForSegment(sectorColor, isDarkMode); + + canvas.save(); + canvas.translate(center.dx, center.dy); + canvas.rotate(centerAngle); + + final String rawLabel = options[i].label; + final textSpan = TextSpan( + text: rawLabel, + style: TextStyle( + color: textColor, + fontSize: fontSize, + fontWeight: FontWeight.w700, + letterSpacing: 0.3, + ), + ); + + final textPainter = TextPainter( + text: textSpan, + textAlign: TextAlign.center, + textDirection: TextDirection.ltr, + maxLines: 1, + ellipsis: '..', + ); + + textPainter.layout(maxWidth: maxLabelWidth); + + // Position text along the radial arm (between center hub and outer rim) + final double radialOffset = innerRadius * 0.42; + textPainter.paint( + canvas, + Offset(radialOffset, -textPainter.height / 2), + ); + + canvas.restore(); + } + } + + void _drawOuterRim(Canvas canvas, Offset center, double radius, double innerRadius) { + // Rim background stroke + final rimColor = isDarkMode ? const Color(0xFF1E1F24) : const Color(0xFF2C2D33); + final Paint rimPaint = Paint() + ..color = rimColor + ..style = PaintingStyle.stroke + ..strokeWidth = (radius - innerRadius); + + canvas.drawCircle(center, (radius + innerRadius) / 2, rimPaint); + + // Subtle outer highlight ring + final Paint highlightPaint = Paint() + ..color = (isDarkMode ? Colors.white : Colors.white).withValues(alpha: isDarkMode ? 0.12 : 0.35) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0; + canvas.drawCircle(center, radius - 0.5, highlightPaint); + + // Inner rim trench line + final Paint trenchPaint = Paint() + ..color = Colors.black.withValues(alpha: isDarkMode ? 0.6 : 0.25) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0; + canvas.drawCircle(center, innerRadius, trenchPaint); + } + + void _drawPerimeterPegs( + Canvas canvas, + Offset center, + double radius, + double innerRadius, + int count, + ) { + final double pegRadius = (radius - innerRadius) * 0.26; + final double pegDistance = (radius + innerRadius) / 2; + + final Paint pegBodyPaint = Paint() + ..color = const Color(0xFFC18A42) // Warm bronze stud + ..style = PaintingStyle.fill; + + final Paint pegHighlightPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.6) + ..style = PaintingStyle.fill; + + for (int i = 0; i < count; i++) { + final double boundaryAngle = SpinWheelMath.getSectorStartAngle(i, count); + final Offset pegCenter = Offset( + center.dx + pegDistance * math.cos(boundaryAngle), + center.dy + pegDistance * math.sin(boundaryAngle), + ); + + // Peg body + canvas.drawCircle(pegCenter, pegRadius, pegBodyPaint); + // Subtle top-left highlight point + canvas.drawCircle( + pegCenter - const Offset(0.5, 0.5), + pegRadius * 0.45, + pegHighlightPaint, + ); + } + } + + void _drawCenterHub(Canvas canvas, Offset center, double radius) { + final double hubRadius = radius * 0.21; + + // 1. Hub Drop Shadow + final Paint shadowPaint = Paint() + ..color = Colors.black.withValues(alpha: isDarkMode ? 0.7 : 0.3) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4.0); + canvas.drawCircle(center + const Offset(0, 2), hubRadius, shadowPaint); + + // 2. Hub Metallic Body + final hubBodyColor = isDarkMode ? const Color(0xFF22242A) : const Color(0xFFF3F0E8); + final Paint hubPaint = Paint() + ..color = hubBodyColor + ..style = PaintingStyle.fill; + canvas.drawCircle(center, hubRadius, hubPaint); + + // 3. Hub Concentric Engraved Ring + final Paint engravedRingPaint = Paint() + ..color = (isDarkMode ? Colors.white : Colors.black).withValues(alpha: 0.15) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2; + canvas.drawCircle(center, hubRadius * 0.85, engravedRingPaint); + + // 4. Hub Outer Chamfer Rim + final Paint bevelPaint = Paint() + ..color = (isDarkMode ? Colors.white : Colors.white).withValues(alpha: isDarkMode ? 0.15 : 0.6) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + canvas.drawCircle(center, hubRadius - 0.75, bevelPaint); + + // 5. PlayMate Signature Diamond Spark in Hub Center + _drawHubBrandMark(canvas, center, hubRadius * 0.44); + } + + void _drawHubBrandMark(Canvas canvas, Offset center, double markRadius) { + final double innerRadius = markRadius * 0.28; + final path = Path(); + + for (int i = 0; i < 4; i++) { + final double outerAngle = i * (math.pi / 2) - (math.pi / 2); + final double innerAngle = outerAngle + (math.pi / 4); + + final ox = center.dx + markRadius * math.cos(outerAngle); + final oy = center.dy + markRadius * math.sin(outerAngle); + + final ix = center.dx + innerRadius * math.cos(innerAngle); + final iy = center.dy + innerRadius * math.sin(innerAngle); + + if (i == 0) { + path.moveTo(ox, oy); + } else { + path.lineTo(ox, oy); + } + path.lineTo(ix, iy); + } + path.close(); + + final Paint markPaint = Paint() + ..color = const Color(0xFFC18A42) // Warm Bronze Accent + ..style = PaintingStyle.fill; + canvas.drawPath(path, markPaint); + + // Micro center point + final Paint dotPaint = Paint() + ..color = isDarkMode ? const Color(0xFF141518) : const Color(0xFFFFFFFF) + ..style = PaintingStyle.fill; + canvas.drawCircle(center, markRadius * 0.18, dotPaint); + } + + @override + bool shouldRepaint(covariant WheelPainter oldDelegate) { + if (oldDelegate.isDarkMode != isDarkMode || + oldDelegate.options.length != options.length) { + return true; + } + for (int i = 0; i < options.length; i++) { + if (oldDelegate.options[i] != options[i]) return true; + } + return false; + } +} + diff --git a/lib/features/spin_wheel/wheel_pointer.dart b/lib/features/spin_wheel/wheel_pointer.dart new file mode 100644 index 0000000..61aad7c --- /dev/null +++ b/lib/features/spin_wheel/wheel_pointer.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +/// Renders the physical fixed pointer mounted at the top frame of the Spin Wheel. +/// +/// Features: +/// - Distinct physical arrowhead pointing down into the active sector +/// - Subtle deflection response as perimeter pegs pass underneath +/// - Metallic highlight, warm bronze trim, and contact drop shadow +class WheelPointer extends StatelessWidget { + final double deflectionAngle; + final bool isDarkMode; + final double size; + + const WheelPointer({ + super.key, + this.deflectionAngle = 0.0, + this.isDarkMode = false, + this.size = 32.0, + }); + + @override + Widget build(BuildContext context) { + return Transform.rotate( + angle: deflectionAngle, + alignment: Alignment.topCenter, + child: SizedBox( + width: size, + height: size * 1.25, + child: CustomPaint( + size: Size(size, size * 1.25), + painter: _PointerPainter(isDarkMode: isDarkMode), + ), + ), + ); + } +} + +class _PointerPainter extends CustomPainter { + final bool isDarkMode; + + const _PointerPainter({required this.isDarkMode}); + + @override + void paint(Canvas canvas, Size size) { + final double w = size.width; + final double h = size.height; + + // Arrowhead path pointing downwards + final path = Path() + ..moveTo(w * 0.18, 0) + ..lineTo(w * 0.82, 0) + ..lineTo(w * 0.82, h * 0.38) + ..lineTo(w * 0.50, h) + ..lineTo(w * 0.18, h * 0.38) + ..close(); + + // 1. Drop Shadow + final Paint shadowPaint = Paint() + ..color = Colors.black.withValues(alpha: isDarkMode ? 0.6 : 0.3) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3.0); + canvas.drawPath(path.shift(const Offset(0, 2)), shadowPaint); + + // 2. Base metallic body + final Color bodyColor = isDarkMode ? const Color(0xFF2E3036) : const Color(0xFFF3F0E8); + final Paint bodyPaint = Paint() + ..color = bodyColor + ..style = PaintingStyle.fill; + canvas.drawPath(path, bodyPaint); + + // 3. Warm bronze outer trim + final Paint trimPaint = Paint() + ..color = const Color(0xFFC18A42) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.8; + canvas.drawPath(path, trimPaint); + + // 4. Center indicator ridge line + final Paint ridgePaint = Paint() + ..color = isDarkMode ? Colors.white.withValues(alpha: 0.25) : Colors.black.withValues(alpha: 0.2) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0; + canvas.drawLine(Offset(w * 0.5, 0), Offset(w * 0.5, h * 0.9), ridgePaint); + + // 5. Pivot cap stud at top + final Paint studPaint = Paint() + ..color = const Color(0xFFC18A42) + ..style = PaintingStyle.fill; + canvas.drawCircle(Offset(w * 0.5, h * 0.16), w * 0.12, studPaint); + + final Paint studDotPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.8) + ..style = PaintingStyle.fill; + canvas.drawCircle(Offset(w * 0.5 - 0.5, h * 0.16 - 0.5), w * 0.04, studDotPaint); + } + + @override + bool shouldRepaint(covariant _PointerPainter oldDelegate) { + return oldDelegate.isDarkMode != isDarkMode; + } +} + diff --git a/lib/features/timer/domain/time_formatter.dart b/lib/features/timer/domain/time_formatter.dart new file mode 100644 index 0000000..030c4ae --- /dev/null +++ b/lib/features/timer/domain/time_formatter.dart @@ -0,0 +1,40 @@ +/// Formatting utilities for timers, stopwatches, and clocks. +class TimeFormatter { + TimeFormatter._(); + + /// Formats duration as MM:SS or HH:MM:SS + static String formatMmSs(Duration d) { + final hours = d.inHours; + final minutes = (d.inMinutes % 60).toString().padLeft(2, '0'); + final seconds = (d.inSeconds % 60).toString().padLeft(2, '0'); + + if (hours > 0) { + return '$hours:$minutes:$seconds'; + } + return '$minutes:$seconds'; + } + + /// Formats stopwatch duration with centiseconds: MM:SS.cc or HH:MM:SS.cc + static String formatStopwatch(Duration d) { + final hours = d.inHours; + final minutes = (d.inMinutes % 60).toString().padLeft(2, '0'); + final seconds = (d.inSeconds % 60).toString().padLeft(2, '0'); + final centiseconds = ((d.inMilliseconds % 1000) ~/ 10).toString().padLeft(2, '0'); + + if (hours > 0) { + return '$hours:$minutes:$seconds.$centiseconds'; + } + return '$minutes:$seconds.$centiseconds'; + } + + /// Formats chess clock: MM:SS or SS.c when under 10 seconds for high-tension blitz + static String formatChess(Duration d) { + if (d.inSeconds < 10 && d > Duration.zero) { + final seconds = d.inSeconds; + final tenths = ((d.inMilliseconds % 1000) ~/ 100); + return '$seconds.${tenths}s'; + } + return formatMmSs(d); + } +} + diff --git a/lib/features/timer/domain/time_source.dart b/lib/features/timer/domain/time_source.dart new file mode 100644 index 0000000..245ec92 --- /dev/null +++ b/lib/features/timer/domain/time_source.dart @@ -0,0 +1,37 @@ +/// Represents an authoritative source of monotonic elapsed time. +/// Allows deterministic testing via mocking or fake clocks. +abstract class TimeSource { + const TimeSource(); + + /// The current monotonic timestamp in microseconds. + int get nowMicroseconds; + + /// The current monotonic timestamp as a [Duration]. + Duration get nowDuration => Duration(microseconds: nowMicroseconds); +} + +/// System-based implementation of [TimeSource]. +class SystemTimeSource extends TimeSource { + const SystemTimeSource(); + + @override + int get nowMicroseconds => DateTime.now().microsecondsSinceEpoch; +} + +/// Fake implementation of [TimeSource] for deterministic unit and widget testing. +class TestTimeSource extends TimeSource { + int _currentMicroseconds; + + TestTimeSource([this._currentMicroseconds = 0]); + + @override + int get nowMicroseconds => _currentMicroseconds; + + void setMicroseconds(int microseconds) { + _currentMicroseconds = microseconds; + } + + void advance(Duration duration) { + _currentMicroseconds += duration.inMicroseconds; + } +} diff --git a/lib/features/timer/domain/timer_models.dart b/lib/features/timer/domain/timer_models.dart new file mode 100644 index 0000000..319ac62 --- /dev/null +++ b/lib/features/timer/domain/timer_models.dart @@ -0,0 +1,173 @@ +import 'package:flutter/foundation.dart'; + +/// Supported timing modes in PlayMate +enum TimerMode { + countdown, + timer, + stopwatch, + turn, + chess, +} + +extension TimerModeDetails on TimerMode { + String get displayName { + switch (this) { + case TimerMode.countdown: + return 'Countdown'; + case TimerMode.timer: + return 'Round Timer'; + case TimerMode.stopwatch: + return 'Stopwatch'; + case TimerMode.turn: + return 'Turn Timer'; + case TimerMode.chess: + return 'Chess Clock'; + } + } + + String get shortDescription { + switch (this) { + case TimerMode.countdown: + return 'Count down to zero'; + case TimerMode.timer: + return 'Work & rest rounds'; + case TimerMode.stopwatch: + return 'Laps & split timing'; + case TimerMode.turn: + return 'Player turn manager'; + case TimerMode.chess: + return 'Dual clock & increments'; + } + } +} + +/// Represents an individual recorded lap in Stopwatch mode. +@immutable +class StopwatchLap { + final int lapNumber; + final Duration splitTime; + final Duration totalTime; + + const StopwatchLap({ + required this.lapNumber, + required this.splitTime, + required this.totalTime, + }); + + Map toJson() => { + 'lapNumber': lapNumber, + 'splitTimeMs': splitTime.inMilliseconds, + 'totalTimeMs': totalTime.inMilliseconds, + }; + + factory StopwatchLap.fromJson(Map json) => StopwatchLap( + lapNumber: json['lapNumber'] as int, + splitTime: Duration(milliseconds: json['splitTimeMs'] as int), + totalTime: Duration(milliseconds: json['totalTimeMs'] as int), + ); +} + +/// Interval phase for General / Round Timer. +enum IntervalPhase { + work, + rest, + complete, +} + +/// Config for Round / Interval Timer. +@immutable +class RoundTimerConfig { + final Duration workDuration; + final Duration restDuration; + final int totalRounds; + + const RoundTimerConfig({ + this.workDuration = const Duration(minutes: 3), + this.restDuration = const Duration(seconds: 30), + this.totalRounds = 3, + }); + + RoundTimerConfig copyWith({ + Duration? workDuration, + Duration? restDuration, + int? totalRounds, + }) { + return RoundTimerConfig( + workDuration: workDuration ?? this.workDuration, + restDuration: restDuration ?? this.restDuration, + totalRounds: totalRounds ?? this.totalRounds, + ); + } +} + +/// Player representation for Turn Timer. +@immutable +class TurnPlayer { + final String id; + final String name; + + const TurnPlayer({ + required this.id, + required this.name, + }); + + TurnPlayer copyWith({ + String? id, + String? name, + }) { + return TurnPlayer( + id: id ?? this.id, + name: name ?? this.name, + ); + } +} + +/// Pre-configured time controls for Chess Clock. +@immutable +class ChessTimeControlPreset { + final String id; + final String name; + final Duration baseTime; + final Duration increment; + + const ChessTimeControlPreset({ + required this.id, + required this.name, + required this.baseTime, + this.increment = Duration.zero, + }); + + static const List presets = [ + ChessTimeControlPreset( + id: 'bullet_1_0', + name: 'Bullet 1+0', + baseTime: Duration(minutes: 1), + increment: Duration.zero, + ), + ChessTimeControlPreset( + id: 'blitz_3_2', + name: 'Blitz 3+2', + baseTime: Duration(minutes: 3), + increment: Duration(seconds: 2), + ), + ChessTimeControlPreset( + id: 'blitz_5_0', + name: 'Blitz 5+0', + baseTime: Duration(minutes: 5), + increment: Duration.zero, + ), + ChessTimeControlPreset( + id: 'rapid_10_0', + name: 'Rapid 10+0', + baseTime: Duration(minutes: 10), + increment: Duration.zero, + ), + ChessTimeControlPreset( + id: 'rapid_15_10', + name: 'Rapid 15+10', + baseTime: Duration(minutes: 15), + increment: Duration(seconds: 10), + ), + ]; +} + diff --git a/lib/features/timer/timer_provider.dart b/lib/features/timer/timer_provider.dart index 81c483e..a78c1cb 100644 --- a/lib/features/timer/timer_provider.dart +++ b/lib/features/timer/timer_provider.dart @@ -1,130 +1,613 @@ import 'dart:async'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../../services/firebase_service.dart'; import '../../services/sound_service.dart'; +import '../../services/storage_service.dart'; +import 'domain/time_source.dart'; import 'timer_state.dart'; +/// Central StateNotifier managing all 5 timer modes in PlayMate with +/// monotonic elapsed-time calculations, zero-drift accuracy, and lifecycle safeguards. class TimerNotifier extends StateNotifier { + final TimeSource _timeSource; Timer? _ticker; - TimerNotifier() : super(TimerState( - mode: TimerMode.countdown, - elapsed: Duration.zero, - remaining: const Duration(minutes: 5), - initialDuration: const Duration(minutes: 5), - isRunning: false, - chessPlayerARemaining: const Duration(minutes: 5), - chessPlayerBRemaining: const Duration(minutes: 5), - isPlayerATurn: true, - )); + // Authoritative monotonic anchor timestamp in microseconds for current running interval + int _intervalStartUs = 0; + + // Snapshot of accumulated time at interval start + Duration _accumulatedCountdownElapsed = Duration.zero; + Duration _accumulatedRoundElapsed = Duration.zero; + Duration _accumulatedStopwatchElapsed = Duration.zero; + Duration _accumulatedTurnElapsed = Duration.zero; + Duration _accumulatedChessPlayerAElapsed = Duration.zero; + Duration _accumulatedChessPlayerBElapsed = Duration.zero; + + // Snapshot of previous lap total time for stopwatch split calculation + Duration _lastLapTotalTime = Duration.zero; + + // Completion debounce flags + bool _hasFiredCountdownComplete = false; + bool _hasFiredRoundComplete = false; + bool _hasFiredTurnComplete = false; + bool _hasFiredChessFlag = false; + + TimerNotifier({TimeSource? timeSource}) + : _timeSource = timeSource ?? const SystemTimeSource(), + super(TimerState.initial()) { + _loadPersistedSettings(); + } + + void _loadPersistedSettings() { + if (!StorageService.isBoxOpen(StorageService.settingsBoxName)) return; + final box = StorageService.getSettingsBox(); + + final countdownSec = box.get('timer_countdown_seconds', defaultValue: 300) as int; + final chessBaseSec = box.get('timer_chess_base_seconds', defaultValue: 300) as int; + final chessIncSec = box.get('timer_chess_inc_seconds', defaultValue: 0) as int; + final chessPreset = box.get('timer_chess_preset', defaultValue: 'blitz_5_0') as String; + + state = state.copyWith( + countdownInitial: Duration(seconds: countdownSec), + countdownRemaining: Duration(seconds: countdownSec), + chessBaseTime: Duration(seconds: chessBaseSec), + chessIncrement: Duration(seconds: chessIncSec), + chessPlayerARemaining: Duration(seconds: chessBaseSec), + chessPlayerBRemaining: Duration(seconds: chessBaseSec), + activePresetId: chessPreset, + ); + } + Future _persistSettings() async { + if (!mounted || !StorageService.isBoxOpen(StorageService.settingsBoxName)) return; + final box = StorageService.getSettingsBox(); + await box.put('timer_countdown_seconds', state.countdownInitial.inSeconds); + if (!mounted) return; + await box.put('timer_chess_base_seconds', state.chessBaseTime.inSeconds); + if (!mounted) return; + await box.put('timer_chess_inc_seconds', state.chessIncrement.inSeconds); + if (!mounted) return; + await box.put('timer_chess_preset', state.activePresetId); + } + + /// Changes the active timer mode. Freezes and resets the running interval. void setMode(TimerMode mode) { + if (state.mode == mode) return; stop(); + state = state.copyWith(mode: mode); + } + + // ========================================== + // CONFIGURATION METHODS + // ========================================== + + void configureCountdown(Duration duration) { + if (duration <= Duration.zero) return; + stop(); + _accumulatedCountdownElapsed = Duration.zero; + _hasFiredCountdownComplete = false; + state = state.copyWith( - mode: mode, - elapsed: Duration.zero, - remaining: mode == TimerMode.stopwatch ? Duration.zero : const Duration(minutes: 5), - initialDuration: const Duration(minutes: 5), - chessPlayerARemaining: const Duration(minutes: 5), - chessPlayerBRemaining: const Duration(minutes: 5), - isPlayerATurn: true, + countdownInitial: duration, + countdownRemaining: duration, + countdownFinished: false, ); + _persistSettings(); } - void configureCountdown(Duration duration) { + void configureRoundTimer(RoundTimerConfig config) { + if (config.workDuration <= Duration.zero || config.totalRounds < 1) return; stop(); + _accumulatedRoundElapsed = Duration.zero; + _hasFiredRoundComplete = false; + state = state.copyWith( - remaining: duration, - initialDuration: duration, - elapsed: Duration.zero, + roundConfig: config, + currentRound: 1, + intervalPhase: IntervalPhase.work, + roundRemaining: config.workDuration, + roundFinished: false, ); } - void configureChess(Duration duration) { + void configureTurnTimer({ + required Duration turnDuration, + List? players, + }) { + if (turnDuration <= Duration.zero) return; stop(); + _accumulatedTurnElapsed = Duration.zero; + _hasFiredTurnComplete = false; + + state = state.copyWith( + turnDuration: turnDuration, + turnRemaining: turnDuration, + turnPlayers: players ?? state.turnPlayers, + activePlayerIndex: 0, + currentTurnRound: 1, + turnExpired: false, + ); + } + + void updateTurnPlayerName(int index, String newName) { + if (index < 0 || index >= state.turnPlayers.length) return; + final trimmed = newName.trim(); + if (trimmed.isEmpty) return; + + final updated = List.from(state.turnPlayers); + updated[index] = updated[index].copyWith(name: trimmed); + state = state.copyWith(turnPlayers: updated); + } + + void addTurnPlayer(String name) { + final trimmed = name.trim(); + if (trimmed.isEmpty || state.turnPlayers.length >= 10) return; + final newPlayer = TurnPlayer( + id: 'p_${DateTime.now().microsecondsSinceEpoch}', + name: trimmed, + ); + state = state.copyWith(turnPlayers: [...state.turnPlayers, newPlayer]); + } + + void removeTurnPlayer(int index) { + if (state.turnPlayers.length <= 2) return; // Need at least 2 players + final updated = List.from(state.turnPlayers)..removeAt(index); + final newActiveIndex = state.activePlayerIndex >= updated.length ? 0 : state.activePlayerIndex; state = state.copyWith( - chessPlayerARemaining: duration, - chessPlayerBRemaining: duration, + turnPlayers: updated, + activePlayerIndex: newActiveIndex, + ); + } + + void configureChess({ + required Duration baseTime, + Duration increment = Duration.zero, + String presetId = 'custom', + }) { + if (baseTime <= Duration.zero) return; + stop(); + _accumulatedChessPlayerAElapsed = Duration.zero; + _accumulatedChessPlayerBElapsed = Duration.zero; + _hasFiredChessFlag = false; + + state = state.copyWith( + chessBaseTime: baseTime, + chessIncrement: increment, + chessPlayerARemaining: baseTime, + chessPlayerBRemaining: baseTime, isPlayerATurn: true, + chessPlayerAMoves: 0, + chessPlayerBMoves: 0, + chessPlayerAFlagged: false, + chessPlayerBFlagged: false, + activePresetId: presetId, ); + _persistSettings(); } + void applyChessPreset(ChessTimeControlPreset preset) { + configureChess( + baseTime: preset.baseTime, + increment: preset.increment, + presetId: preset.id, + ); + } + + // ========================================== + // CORE TIMING CONTROLS (START / PAUSE / RESET) + // ========================================== + + /// Authoritative Start: commits running timestamp anchor and begins UI refresh loop. void start() { if (state.isRunning) return; + + // Guard against starting an already expired state without reset + if (state.mode == TimerMode.countdown && state.countdownFinished) return; + if (state.mode == TimerMode.timer && state.roundFinished) return; + if (state.mode == TimerMode.chess && (state.chessPlayerAFlagged || state.chessPlayerBFlagged)) return; + + _intervalStartUs = _timeSource.nowMicroseconds; state = state.copyWith(isRunning: true); - - _ticker = Timer.periodic(const Duration(seconds: 1), (timer) { - _tick(); - }); + + // High precision tick interval (50ms for stopwatch centiseconds, 100ms for timers) + final Duration tickInterval = state.mode == TimerMode.stopwatch + ? const Duration(milliseconds: 35) + : const Duration(milliseconds: 80); + + _ticker?.cancel(); + _ticker = Timer.periodic(tickInterval, (_) => _onAuthoritativeTick()); AnalyticsService.trackTimerStarted(state.mode.name); } + /// Authoritative Pause: freezes accumulated elapsed time and stops ticker. void stop() { + if (!state.isRunning) return; + _ticker?.cancel(); - state = state.copyWith(isRunning: false); + _ticker = null; + + // Freeze monotonic delta into accumulated counters + final int nowUs = _timeSource.nowMicroseconds; + final Duration intervalDelta = Duration(microseconds: nowUs - _intervalStartUs); + + _commitIntervalDelta(intervalDelta); + + Duration? updatedCountdownRemaining; + Duration? updatedStopwatchElapsed; + Duration? updatedTurnRemaining; + Duration? updatedRoundRemaining; + Duration? updatedChessPlayerARemaining; + Duration? updatedChessPlayerBRemaining; + + switch (state.mode) { + case TimerMode.countdown: + updatedCountdownRemaining = state.countdownInitial - _accumulatedCountdownElapsed; + if (updatedCountdownRemaining < Duration.zero) updatedCountdownRemaining = Duration.zero; + break; + case TimerMode.timer: + final target = state.intervalPhase == IntervalPhase.work + ? state.roundConfig.workDuration + : state.roundConfig.restDuration; + updatedRoundRemaining = target - _accumulatedRoundElapsed; + if (updatedRoundRemaining < Duration.zero) updatedRoundRemaining = Duration.zero; + break; + case TimerMode.stopwatch: + updatedStopwatchElapsed = _accumulatedStopwatchElapsed; + break; + case TimerMode.turn: + updatedTurnRemaining = state.turnDuration - _accumulatedTurnElapsed; + if (updatedTurnRemaining < Duration.zero) updatedTurnRemaining = Duration.zero; + break; + case TimerMode.chess: + if (state.isPlayerATurn) { + updatedChessPlayerARemaining = state.chessBaseTime - _accumulatedChessPlayerAElapsed; + if (updatedChessPlayerARemaining < Duration.zero) updatedChessPlayerARemaining = Duration.zero; + } else { + updatedChessPlayerBRemaining = state.chessBaseTime - _accumulatedChessPlayerBElapsed; + if (updatedChessPlayerBRemaining < Duration.zero) updatedChessPlayerBRemaining = Duration.zero; + } + break; + } + + state = state.copyWith( + isRunning: false, + countdownRemaining: updatedCountdownRemaining, + roundRemaining: updatedRoundRemaining, + stopwatchElapsed: updatedStopwatchElapsed, + turnRemaining: updatedTurnRemaining, + chessPlayerARemaining: updatedChessPlayerARemaining, + chessPlayerBRemaining: updatedChessPlayerBRemaining, + ); } + /// Authoritative Reset: invalidates previous interval and restores initial values. void reset() { stop(); + _intervalStartUs = 0; + + switch (state.mode) { + case TimerMode.countdown: + _accumulatedCountdownElapsed = Duration.zero; + _hasFiredCountdownComplete = false; + state = state.copyWith( + countdownRemaining: state.countdownInitial, + countdownFinished: false, + ); + break; + + case TimerMode.timer: + _accumulatedRoundElapsed = Duration.zero; + _hasFiredRoundComplete = false; + state = state.copyWith( + currentRound: 1, + intervalPhase: IntervalPhase.work, + roundRemaining: state.roundConfig.workDuration, + roundFinished: false, + ); + break; + + case TimerMode.stopwatch: + _accumulatedStopwatchElapsed = Duration.zero; + _lastLapTotalTime = Duration.zero; + state = state.copyWith( + stopwatchElapsed: Duration.zero, + stopwatchLaps: const [], + ); + break; + + case TimerMode.turn: + _accumulatedTurnElapsed = Duration.zero; + _hasFiredTurnComplete = false; + state = state.copyWith( + turnRemaining: state.turnDuration, + activePlayerIndex: 0, + currentTurnRound: 1, + turnExpired: false, + ); + break; + + case TimerMode.chess: + _accumulatedChessPlayerAElapsed = Duration.zero; + _accumulatedChessPlayerBElapsed = Duration.zero; + _hasFiredChessFlag = false; + state = state.copyWith( + chessPlayerARemaining: state.chessBaseTime, + chessPlayerBRemaining: state.chessBaseTime, + isPlayerATurn: true, + chessPlayerAMoves: 0, + chessPlayerBMoves: 0, + chessPlayerAFlagged: false, + chessPlayerBFlagged: false, + ); + break; + } + } + + // ========================================== + // MODE SPECIFIC ACTIONS + // ========================================== + + /// Records a lap in Stopwatch mode. + void recordStopwatchLap() { + if (state.mode != TimerMode.stopwatch || !state.isRunning) return; + + final int nowUs = _timeSource.nowMicroseconds; + final Duration currentIntervalDelta = Duration(microseconds: nowUs - _intervalStartUs); + final Duration currentTotal = _accumulatedStopwatchElapsed + currentIntervalDelta; + final Duration split = currentTotal - _lastLapTotalTime; + _lastLapTotalTime = currentTotal; + + final newLap = StopwatchLap( + lapNumber: state.stopwatchLaps.length + 1, + splitTime: split, + totalTime: currentTotal, + ); + + SoundService.triggerHaptic(); state = state.copyWith( - elapsed: Duration.zero, - remaining: state.initialDuration, - chessPlayerARemaining: state.initialDuration, - chessPlayerBRemaining: state.initialDuration, - isPlayerATurn: true, + stopwatchElapsed: currentTotal, + stopwatchLaps: [newLap, ...state.stopwatchLaps], + ); + } + + /// Advances to the next player in Turn Timer mode. + void advanceTurn() { + if (state.mode != TimerMode.turn) return; + + SoundService.triggerHaptic(); + + // Reset turn accumulation + _accumulatedTurnElapsed = Duration.zero; + _intervalStartUs = _timeSource.nowMicroseconds; + _hasFiredTurnComplete = false; + + final nextIndex = (state.activePlayerIndex + 1) % state.turnPlayers.length; + final isNewRound = nextIndex == 0; + + state = state.copyWith( + activePlayerIndex: nextIndex, + turnRemaining: state.turnDuration, + turnExpired: false, + currentTurnRound: isNewRound ? state.currentTurnRound + 1 : state.currentTurnRound, ); } + /// Switches active player clock in Chess Clock mode with atomic Fischer increment. void switchChessTurn() { - if (state.mode != TimerMode.chess) return; + if (state.mode != TimerMode.chess || !state.isRunning) return; + if (state.chessPlayerAFlagged || state.chessPlayerBFlagged) return; + SoundService.triggerHaptic(); - state = state.copyWith(isPlayerATurn: !state.isPlayerATurn); + + // 1. Commit current interval + final int nowUs = _timeSource.nowMicroseconds; + final Duration delta = Duration(microseconds: nowUs - _intervalStartUs); + _intervalStartUs = nowUs; // reset anchor for new player + + if (state.isPlayerATurn) { + _accumulatedChessPlayerAElapsed += delta; + // Apply increment to Player A who just finished move + final remaining = (state.chessBaseTime - _accumulatedChessPlayerAElapsed + state.chessIncrement); + state = state.copyWith( + isPlayerATurn: false, + chessPlayerARemaining: remaining > Duration.zero ? remaining : Duration.zero, + chessPlayerAMoves: state.chessPlayerAMoves + 1, + ); + // Adjust accumulated so future ticks calculate from the incremented value + _accumulatedChessPlayerAElapsed = state.chessBaseTime - state.chessPlayerARemaining; + } else { + _accumulatedChessPlayerBElapsed += delta; + // Apply increment to Player B who just finished move + final remaining = (state.chessBaseTime - _accumulatedChessPlayerBElapsed + state.chessIncrement); + state = state.copyWith( + isPlayerATurn: true, + chessPlayerBRemaining: remaining > Duration.zero ? remaining : Duration.zero, + chessPlayerBMoves: state.chessPlayerBMoves + 1, + ); + _accumulatedChessPlayerBElapsed = state.chessBaseTime - state.chessPlayerBRemaining; + } } - void _tick() { + // ========================================== + // AUTHORITATIVE TICK CALCULATION + // ========================================== + + void _onAuthoritativeTick() { + if (!state.isRunning) return; + + final int nowUs = _timeSource.nowMicroseconds; + final Duration currentIntervalDelta = Duration(microseconds: nowUs - _intervalStartUs); + switch (state.mode) { case TimerMode.countdown: - case TimerMode.turn: - if (state.remaining.inSeconds <= 1) { + final totalElapsed = _accumulatedCountdownElapsed + currentIntervalDelta; + final remaining = state.countdownInitial - totalElapsed; + + if (remaining <= Duration.zero) { stop(); - SoundService.triggerHaptic(); - state = state.copyWith(remaining: Duration.zero); - } else { state = state.copyWith( - remaining: state.remaining - const Duration(seconds: 1), - elapsed: state.elapsed + const Duration(seconds: 1), + countdownRemaining: Duration.zero, + countdownFinished: true, ); + if (!_hasFiredCountdownComplete) { + _hasFiredCountdownComplete = true; + HapticFeedback.heavyImpact(); + SoundService.playRollSound(); + } + } else { + state = state.copyWith(countdownRemaining: remaining); } break; + case TimerMode.timer: + _handleRoundTimerTick(currentIntervalDelta); + break; + case TimerMode.stopwatch: + final totalElapsed = _accumulatedStopwatchElapsed + currentIntervalDelta; + state = state.copyWith(stopwatchElapsed: totalElapsed); + break; + + case TimerMode.turn: + final totalElapsed = _accumulatedTurnElapsed + currentIntervalDelta; + final remaining = state.turnDuration - totalElapsed; + + if (remaining <= Duration.zero) { + state = state.copyWith( + turnRemaining: Duration.zero, + turnExpired: true, + ); + if (!_hasFiredTurnComplete) { + _hasFiredTurnComplete = true; + HapticFeedback.heavyImpact(); + SoundService.playRollSound(); + } + } else { + state = state.copyWith(turnRemaining: remaining); + } + break; + + case TimerMode.chess: + _handleChessTick(currentIntervalDelta); + break; + } + } + + void _handleRoundTimerTick(Duration currentIntervalDelta) { + final targetDuration = state.intervalPhase == IntervalPhase.work + ? state.roundConfig.workDuration + : state.roundConfig.restDuration; + + final totalElapsed = _accumulatedRoundElapsed + currentIntervalDelta; + final remaining = targetDuration - totalElapsed; + + if (remaining <= Duration.zero) { + // Transition between Work and Rest or advance Round + _accumulatedRoundElapsed = Duration.zero; + _intervalStartUs = _timeSource.nowMicroseconds; + HapticFeedback.mediumImpact(); + + if (state.intervalPhase == IntervalPhase.work) { + if (state.roundConfig.restDuration > Duration.zero) { + state = state.copyWith( + intervalPhase: IntervalPhase.rest, + roundRemaining: state.roundConfig.restDuration, + ); + } else { + _advanceRound(); + } + } else { + _advanceRound(); + } + } else { + state = state.copyWith(roundRemaining: remaining); + } + } + + void _advanceRound() { + if (state.currentRound >= state.roundConfig.totalRounds) { + stop(); + state = state.copyWith( + intervalPhase: IntervalPhase.complete, + roundRemaining: Duration.zero, + roundFinished: true, + ); + if (!_hasFiredRoundComplete) { + _hasFiredRoundComplete = true; + HapticFeedback.heavyImpact(); + SoundService.playRollSound(); + } + } else { + state = state.copyWith( + currentRound: state.currentRound + 1, + intervalPhase: IntervalPhase.work, + roundRemaining: state.roundConfig.workDuration, + ); + } + } + + void _handleChessTick(Duration currentIntervalDelta) { + if (state.isPlayerATurn) { + final totalElapsed = _accumulatedChessPlayerAElapsed + currentIntervalDelta; + final remaining = state.chessBaseTime - totalElapsed; + + if (remaining <= Duration.zero) { + stop(); state = state.copyWith( - elapsed: state.elapsed + const Duration(seconds: 1), + chessPlayerARemaining: Duration.zero, + chessPlayerAFlagged: true, ); - break; + if (!_hasFiredChessFlag) { + _hasFiredChessFlag = true; + HapticFeedback.heavyImpact(); + SoundService.playRollSound(); + } + } else { + state = state.copyWith(chessPlayerARemaining: remaining); + } + } else { + final totalElapsed = _accumulatedChessPlayerBElapsed + currentIntervalDelta; + final remaining = state.chessBaseTime - totalElapsed; + + if (remaining <= Duration.zero) { + stop(); + state = state.copyWith( + chessPlayerBRemaining: Duration.zero, + chessPlayerBFlagged: true, + ); + if (!_hasFiredChessFlag) { + _hasFiredChessFlag = true; + HapticFeedback.heavyImpact(); + SoundService.playRollSound(); + } + } else { + state = state.copyWith(chessPlayerBRemaining: remaining); + } + } + } + void _commitIntervalDelta(Duration delta) { + switch (state.mode) { + case TimerMode.countdown: + _accumulatedCountdownElapsed += delta; + break; + case TimerMode.timer: + _accumulatedRoundElapsed += delta; + break; + case TimerMode.stopwatch: + _accumulatedStopwatchElapsed += delta; + break; + case TimerMode.turn: + _accumulatedTurnElapsed += delta; + break; case TimerMode.chess: if (state.isPlayerATurn) { - if (state.chessPlayerARemaining.inSeconds <= 1) { - stop(); - SoundService.triggerHaptic(); - state = state.copyWith(chessPlayerARemaining: Duration.zero); - } else { - state = state.copyWith( - chessPlayerARemaining: state.chessPlayerARemaining - const Duration(seconds: 1), - ); - } + _accumulatedChessPlayerAElapsed += delta; } else { - if (state.chessPlayerBRemaining.inSeconds <= 1) { - stop(); - SoundService.triggerHaptic(); - state = state.copyWith(chessPlayerBRemaining: Duration.zero); - } else { - state = state.copyWith( - chessPlayerBRemaining: state.chessPlayerBRemaining - const Duration(seconds: 1), - ); - } + _accumulatedChessPlayerBElapsed += delta; } break; } diff --git a/lib/features/timer/timer_screen.dart b/lib/features/timer/timer_screen.dart index 4f9b992..8e0d122 100644 --- a/lib/features/timer/timer_screen.dart +++ b/lib/features/timer/timer_screen.dart @@ -1,8 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../../shared/responsive_layout.dart'; +import 'domain/time_formatter.dart'; import 'timer_provider.dart'; import 'timer_state.dart'; +import 'widgets/lap_list.dart'; +import 'widgets/time_display.dart'; +import 'widgets/timer_controls.dart'; +import 'widgets/timer_mode_selector.dart'; class TimerScreen extends ConsumerStatefulWidget { const TimerScreen({super.key}); @@ -12,55 +18,498 @@ class TimerScreen extends ConsumerStatefulWidget { } class _TimerScreenState extends ConsumerState { - final TextEditingController _minutesController = TextEditingController(text: '5'); + // Custom duration controllers + final TextEditingController _customMinutesController = TextEditingController(text: '5'); + final TextEditingController _customSecondsController = TextEditingController(text: '0'); - String _formatDuration(Duration d) { - final minutes = d.inMinutes.toString().padLeft(2, '0'); - final seconds = (d.inSeconds % 60).toString().padLeft(2, '0'); - return '$minutes:$seconds'; - } + // Round timer controllers + final TextEditingController _workMinController = TextEditingController(text: '3'); + final TextEditingController _restSecController = TextEditingController(text: '30'); + final TextEditingController _roundsController = TextEditingController(text: '3'); - void _applyCustomDuration() { - final mins = int.tryParse(_minutesController.text) ?? 5; - final duration = Duration(minutes: mins); - final currentMode = ref.read(timerProvider).mode; - if (currentMode == TimerMode.chess) { - ref.read(timerProvider.notifier).configureChess(duration); - } else { - ref.read(timerProvider.notifier).configureCountdown(duration); - } - } + // Player name controller for turn timer + final TextEditingController _newPlayerController = TextEditingController(); @override void dispose() { - _minutesController.dispose(); + _customMinutesController.dispose(); + _customSecondsController.dispose(); + _workMinController.dispose(); + _restSecController.dispose(); + _roundsController.dispose(); + _newPlayerController.dispose(); super.dispose(); } + // ========================================== + // BOTTOM SHEETS & DIALOGS + // ========================================== + + void _showCountdownPresetsSheet(BuildContext context) { + final notifier = ref.read(timerProvider.notifier); + final presets = [ + const Duration(seconds: 30), + const Duration(minutes: 1), + const Duration(minutes: 2), + const Duration(minutes: 3), + const Duration(minutes: 5), + const Duration(minutes: 10), + const Duration(minutes: 15), + const Duration(minutes: 30), + const Duration(hours: 1), + ]; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.m), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Countdown Presets', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + const Divider(), + Wrap( + spacing: 8, + runSpacing: 8, + children: presets.map((duration) { + final label = duration.inMinutes == 0 + ? '${duration.inSeconds}s' + : duration.inHours == 0 + ? '${duration.inMinutes}m' + : '${duration.inHours}h'; + return ActionChip( + label: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + onPressed: () { + notifier.configureCountdown(duration); + Navigator.pop(ctx); + }, + ); + }).toList(), + ), + const SizedBox(height: 16), + const Text( + 'Custom Duration', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: _customMinutesController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Minutes', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _customSecondsController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Seconds', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () { + final mins = int.tryParse(_customMinutesController.text) ?? 0; + final secs = int.tryParse(_customSecondsController.text) ?? 0; + final total = Duration(minutes: mins, seconds: secs); + if (total > Duration.zero) { + notifier.configureCountdown(total); + Navigator.pop(ctx); + } + }, + child: const Text('SET'), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } + + void _showChessPresetsSheet(BuildContext context) { + final notifier = ref.read(timerProvider.notifier); + final activeId = ref.read(timerProvider).activePresetId; + + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.m), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.m), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Chess Time Controls', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + ), + const Divider(), + ...ChessTimeControlPreset.presets.map((preset) { + final isSelected = activeId == preset.id; + final incText = preset.increment > Duration.zero + ? ' (+${preset.increment.inSeconds}s inc)' + : ''; + return ListTile( + title: Text( + preset.name, + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, + color: isSelected ? const Color(0xFFC18A42) : null, + ), + ), + subtitle: Text('${preset.baseTime.inMinutes} mins$incText'), + trailing: isSelected + ? const Icon(Icons.check_circle, color: Color(0xFFC18A42)) + : null, + onTap: () { + notifier.applyChessPreset(preset); + Navigator.pop(ctx); + }, + ); + }), + ], + ), + ), + ); + }, + ); + } + + void _showTurnPlayersSheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) { + return Consumer( + builder: (context, ref, _) { + final timerState = ref.watch(timerProvider); + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.65, + maxChildSize: 0.9, + minChildSize: 0.4, + builder: (_, scrollController) { + return SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Players (${timerState.turnPlayers.length})', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _newPlayerController, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + hintText: 'Add player name', + border: OutlineInputBorder(), + isDense: true, + ), + onSubmitted: (val) { + if (val.trim().isNotEmpty) { + ref.read(timerProvider.notifier).addTurnPlayer(val); + _newPlayerController.clear(); + } + }, + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () { + if (_newPlayerController.text.trim().isNotEmpty) { + ref + .read(timerProvider.notifier) + .addTurnPlayer(_newPlayerController.text); + _newPlayerController.clear(); + } + }, + child: const Text('ADD'), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: ListView.builder( + controller: scrollController, + itemCount: timerState.turnPlayers.length, + itemBuilder: (context, index) { + final player = timerState.turnPlayers[index]; + final canDelete = timerState.turnPlayers.length > 2; + + return ListTile( + leading: CircleAvatar( + radius: 14, + backgroundColor: const Color(0xFFC18A42).withValues(alpha: 0.2), + child: Text( + '${index + 1}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Color(0xFFC18A42), + ), + ), + ), + title: Text(player.name), + trailing: IconButton( + icon: Icon( + Icons.delete_outline, + color: canDelete ? Colors.red.shade400 : Colors.grey, + ), + onPressed: canDelete + ? () => ref.read(timerProvider.notifier).removeTurnPlayer(index) + : null, + tooltip: canDelete ? 'Remove player' : 'Minimum 2 players required', + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + }, + ); + }, + ); + } + + void _showRoundConfigSheet(BuildContext context) { + final notifier = ref.read(timerProvider.notifier); + final currentConfig = ref.read(timerProvider).roundConfig; + + _workMinController.text = (currentConfig.workDuration.inMinutes).toString(); + _restSecController.text = (currentConfig.restDuration.inSeconds).toString(); + _roundsController.text = currentConfig.totalRounds.toString(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.m), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Configure Round Timer', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + const Divider(), + Row( + children: [ + Expanded( + child: TextField( + controller: _workMinController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Work (Mins)', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _restSecController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Rest (Secs)', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _roundsController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Rounds', + border: OutlineInputBorder(), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () { + final workMins = int.tryParse(_workMinController.text) ?? 3; + final restSecs = int.tryParse(_restSecController.text) ?? 30; + final rounds = int.tryParse(_roundsController.text) ?? 3; + + notifier.configureRoundTimer(RoundTimerConfig( + workDuration: Duration(minutes: workMins), + restDuration: Duration(seconds: restSecs), + totalRounds: rounds, + )); + Navigator.pop(ctx); + }, + child: const Text('APPLY SETTINGS'), + ), + ), + ], + ), + ), + ); + }, + ); + } + + // ========================================== + // MAIN BUILD + // ========================================== + @override Widget build(BuildContext context) { final timerState = ref.watch(timerProvider); final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; return Scaffold( appBar: AppBar( title: const Text('Timers'), + actions: [ + if (timerState.mode == TimerMode.countdown) + IconButton( + icon: const Icon(Icons.tune), + onPressed: timerState.isRunning ? null : () => _showCountdownPresetsSheet(context), + tooltip: 'Presets & Duration', + ), + if (timerState.mode == TimerMode.timer) + IconButton( + icon: const Icon(Icons.settings), + onPressed: timerState.isRunning ? null : () => _showRoundConfigSheet(context), + tooltip: 'Round Settings', + ), + if (timerState.mode == TimerMode.turn) + IconButton( + icon: const Icon(Icons.group), + onPressed: timerState.isRunning ? null : () => _showTurnPlayersSheet(context), + tooltip: 'Manage Players', + ), + if (timerState.mode == TimerMode.chess) + IconButton( + icon: const Icon(Icons.tune), + onPressed: timerState.isRunning ? null : () => _showChessPresetsSheet(context), + tooltip: 'Time Controls', + ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => ref.read(timerProvider.notifier).reset(), + tooltip: 'Reset', + ), + ], ), body: SafeArea( child: ResponsiveLayout( mobile: Column( children: [ - _buildModeSelector(timerState, theme), - if (timerState.mode == TimerMode.countdown || timerState.mode == TimerMode.chess) - _buildConfigurationPanel(theme), + // 1. Top Mode Selector Chip Row + TimerModeSelector( + selectedMode: timerState.mode, + onModeChanged: (mode) => ref.read(timerProvider.notifier).setMode(mode), + ), + + // 2. Mode Content Body Expanded( child: Center( - child: timerState.mode == TimerMode.chess - ? _buildChessTimerView(timerState, theme) - : _buildStandardTimerView(timerState, theme), + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.m), + child: _buildActiveModeContent(timerState, theme, isDark), + ), ), ), - if (timerState.mode != TimerMode.chess) _buildStandardControls(timerState, theme), + + // 3. Bottom Controls (Start/Pause, Reset, and Mode Actions) + if (timerState.mode != TimerMode.chess) + _buildModeControls(timerState), ], ), ), @@ -68,118 +517,222 @@ class _TimerScreenState extends ConsumerState { ); } - Widget _buildModeSelector(TimerState timerState, ThemeData theme) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.all(AppSpacing.m), - child: Row( - children: TimerMode.values.map((mode) { - final isSelected = timerState.mode == mode; - return Padding( - padding: const EdgeInsets.only(right: AppSpacing.s), - child: ChoiceChip( - label: Text(mode.name.toUpperCase()), - selected: isSelected, - onSelected: (selected) { - if (selected) { - ref.read(timerProvider.notifier).setMode(mode); - } - }, + Widget _buildActiveModeContent(TimerState timerState, ThemeData theme, bool isDark) { + switch (timerState.mode) { + case TimerMode.countdown: + return _buildCountdownView(timerState, theme); + case TimerMode.timer: + return _buildRoundTimerView(timerState, theme, isDark); + case TimerMode.stopwatch: + return _buildStopwatchView(timerState, theme); + case TimerMode.turn: + return _buildTurnTimerView(timerState, theme, isDark); + case TimerMode.chess: + return _buildChessClockView(timerState, theme, isDark); + } + } + + // ========================================== + // MODE VIEWS + // ========================================== + + Widget _buildCountdownView(TimerState timerState, ThemeData theme) { + final formatted = TimeFormatter.formatMmSs(timerState.countdownRemaining); + final subtitle = timerState.countdownFinished + ? 'COMPLETED!' + : timerState.isRunning + ? 'Counting down' + : 'Paused / Ready'; + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TimeDisplay( + formattedTime: formatted, + subtitle: subtitle, + fontSize: 84, + isFlagged: timerState.countdownFinished, + ), + const SizedBox(height: 16), + InkWell( + onTap: timerState.isRunning ? null : () => _showCountdownPresetsSheet(context), + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFC18A42).withValues(alpha: 0.3)), ), - ); - }).toList(), - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.timer_outlined, size: 16, color: Color(0xFFC18A42)), + const SizedBox(width: 6), + Text( + 'Initial: ${TimeFormatter.formatMmSs(timerState.countdownInitial)}', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + const SizedBox(width: 4), + const Icon(Icons.arrow_drop_down, size: 16, color: Color(0xFFC18A42)), + ], + ), + ), + ), + ], ); } - Widget _buildConfigurationPanel(ThemeData theme) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.m), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _minutesController, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: 'Duration (Minutes)', - border: OutlineInputBorder(), - ), - ), + Widget _buildRoundTimerView(TimerState timerState, ThemeData theme, bool isDark) { + final formatted = TimeFormatter.formatMmSs(timerState.roundRemaining); + final isWork = timerState.intervalPhase == IntervalPhase.work; + final isDone = timerState.roundFinished; + + final badgeColor = isDone + ? Colors.grey + : isWork + ? (isDark ? const Color(0xFFC18A42) : const Color(0xFF1A1A1A)) + : Colors.blueAccent; + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: badgeColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: badgeColor, width: 1.0), ), - const SizedBox(width: AppSpacing.s), - ElevatedButton( - onPressed: _applyCustomDuration, - child: const Text('APPLY'), + child: Text( + isDone + ? 'WORKOUT FINISHED' + : '${isWork ? "WORK" : "REST"} • ROUND ${timerState.currentRound} / ${timerState.roundConfig.totalRounds}', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + letterSpacing: 0.5, + color: isDark ? Colors.white : Colors.black87, + ), ), - ], - ), + ), + const SizedBox(height: 16), + TimeDisplay( + formattedTime: formatted, + subtitle: isDone + ? 'Great effort!' + : timerState.isRunning + ? 'Stay focused' + : 'Paused', + fontSize: 84, + isFlagged: isDone, + ), + ], ); } - Widget _buildStandardTimerView(TimerState timerState, ThemeData theme) { - final displayTime = timerState.mode == TimerMode.stopwatch - ? _formatDuration(timerState.elapsed) - : _formatDuration(timerState.remaining); - - return Text( - displayTime, - style: theme.textTheme.headlineLarge?.copyWith( - fontSize: 80, - fontWeight: FontWeight.w900, - fontFamily: 'monospace', - ), + Widget _buildStopwatchView(TimerState timerState, ThemeData theme) { + final formatted = TimeFormatter.formatStopwatch(timerState.stopwatchElapsed); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TimeDisplay( + formattedTime: formatted, + subtitle: timerState.isRunning ? 'Running' : 'Ready / Paused', + fontSize: 66, + ), + const SizedBox(height: 20), + LapList(laps: timerState.stopwatchLaps), + ], ); } - Widget _buildChessTimerView(TimerState timerState, ThemeData theme) { + Widget _buildTurnTimerView(TimerState timerState, ThemeData theme, bool isDark) { + final activePlayer = timerState.turnPlayers[timerState.activePlayerIndex]; + final nextPlayerIndex = (timerState.activePlayerIndex + 1) % timerState.turnPlayers.length; + final nextPlayer = timerState.turnPlayers[nextPlayerIndex]; + + final formatted = TimeFormatter.formatMmSs(timerState.turnRemaining); + return Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - // Player B (Top) - Rotated for face to face play - Expanded( - child: RotatedBox( - quarterTurns: 2, - child: InkWell( - onTap: !timerState.isPlayerATurn && timerState.isRunning - ? () => ref.read(timerProvider.notifier).switchChessTurn() - : null, - child: Container( - color: !timerState.isPlayerATurn - ? theme.colorScheme.primary.withValues(alpha: 0.1) - : Colors.transparent, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('PLAYER B', style: theme.textTheme.titleLarge), - Text( - _formatDuration(timerState.chessPlayerBRemaining), - style: theme.textTheme.headlineLarge?.copyWith( - fontSize: 60, - fontWeight: FontWeight.w900, - fontFamily: 'monospace', - ), - ), - if (!timerState.isPlayerATurn && timerState.isRunning) - const Padding( - padding: EdgeInsets.only(top: 8.0), - child: Text('TAP TO END TURN', style: TextStyle(fontWeight: FontWeight.bold)), - ), - ], - ), - ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFC18A42).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFC18A42), width: 1.2), + ), + child: Column( + children: [ + Text( + 'ROUND ${timerState.currentTurnRound}', + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, letterSpacing: 0.8), ), - ), + const SizedBox(height: 2), + Text( + activePlayer.name.toUpperCase(), + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w900), + ), + ], ), ), + const SizedBox(height: 16), + TimeDisplay( + formattedTime: formatted, + subtitle: timerState.turnExpired + ? "TIME'S UP!" + : 'Up Next: ${nextPlayer.name}', + fontSize: 80, + isFlagged: timerState.turnExpired, + ), + ], + ); + } + + Widget _buildChessClockView(TimerState timerState, ThemeData theme, bool isDark) { + return Column( + children: [ + // Player B (Top) - Rotated 180 degrees for face-to-face play + RotatedBox( + quarterTurns: 2, + child: _buildChessPlayerPad( + playerName: 'PLAYER B', + remainingTime: timerState.chessPlayerBRemaining, + moves: timerState.chessPlayerBMoves, + isActive: !timerState.isPlayerATurn && timerState.isRunning, + isFlagged: timerState.chessPlayerBFlagged, + theme: theme, + isDark: isDark, + onTap: () { + if (!timerState.isPlayerATurn && timerState.isRunning) { + ref.read(timerProvider.notifier).switchChessTurn(); + } + }, + ), + ), + + // Middle Control Strip Container( height: 60, + padding: const EdgeInsets.symmetric(horizontal: 16), color: theme.colorScheme.surface, child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ IconButton( - icon: Icon(timerState.isRunning ? Icons.pause : Icons.play_arrow), + icon: const Icon(Icons.refresh), + onPressed: () => ref.read(timerProvider.notifier).reset(), + tooltip: 'Reset Game', + ), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), onPressed: () { if (timerState.isRunning) { ref.read(timerProvider.notifier).stop(); @@ -187,82 +740,140 @@ class _TimerScreenState extends ConsumerState { ref.read(timerProvider.notifier).start(); } }, + icon: Icon(timerState.isRunning ? Icons.pause : Icons.play_arrow, size: 18), + label: Text(timerState.isRunning ? 'PAUSE' : 'START'), ), IconButton( - icon: const Icon(Icons.refresh), - onPressed: () => ref.read(timerProvider.notifier).reset(), + icon: const Icon(Icons.tune), + onPressed: timerState.isRunning ? null : () => _showChessPresetsSheet(context), + tooltip: 'Time Controls', ), ], ), ), + // Player A (Bottom) - Expanded( - child: InkWell( - onTap: timerState.isPlayerATurn && timerState.isRunning - ? () => ref.read(timerProvider.notifier).switchChessTurn() - : null, - child: Container( - color: timerState.isPlayerATurn - ? theme.colorScheme.primary.withValues(alpha: 0.1) - : Colors.transparent, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('PLAYER A', style: theme.textTheme.titleLarge), - Text( - _formatDuration(timerState.chessPlayerARemaining), - style: theme.textTheme.headlineLarge?.copyWith( - fontSize: 60, - fontWeight: FontWeight.w900, - fontFamily: 'monospace', - ), - ), - if (timerState.isPlayerATurn && timerState.isRunning) - const Padding( - padding: EdgeInsets.only(top: 8.0), - child: Text('TAP TO END TURN', style: TextStyle(fontWeight: FontWeight.bold)), - ), - ], - ), - ), - ), - ), + _buildChessPlayerPad( + playerName: 'PLAYER A', + remainingTime: timerState.chessPlayerARemaining, + moves: timerState.chessPlayerAMoves, + isActive: timerState.isPlayerATurn && timerState.isRunning, + isFlagged: timerState.chessPlayerAFlagged, + theme: theme, + isDark: isDark, + onTap: () { + if (timerState.isPlayerATurn && timerState.isRunning) { + ref.read(timerProvider.notifier).switchChessTurn(); + } + }, ), ], ); } - Widget _buildStandardControls(TimerState timerState, ThemeData theme) { - return Padding( - padding: const EdgeInsets.all(AppSpacing.m), - child: Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () => ref.read(timerProvider.notifier).reset(), - child: const Text('RESET'), + Widget _buildChessPlayerPad({ + required String playerName, + required Duration remainingTime, + required int moves, + required bool isActive, + required bool isFlagged, + required ThemeData theme, + required bool isDark, + required VoidCallback onTap, + }) { + final bgColor = isFlagged + ? Colors.red.withValues(alpha: 0.2) + : isActive + ? (isDark + ? const Color(0xFFC18A42).withValues(alpha: 0.2) + : const Color(0xFF1A1A1A).withValues(alpha: 0.08)) + : Colors.transparent; + + final borderColor = isActive ? const Color(0xFFC18A42) : Colors.transparent; + + return InkWell( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + decoration: BoxDecoration( + color: bgColor, + border: Border.all(color: borderColor, width: 2.0), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + playerName, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + color: isActive ? const Color(0xFFC18A42) : theme.colorScheme.secondary, + ), + ), + if (moves > 0) ...[ + const SizedBox(width: 8), + Text( + '($moves moves)', + style: TextStyle(fontSize: 12, color: theme.colorScheme.secondary), + ), + ], + ], ), - ), - const SizedBox(width: AppSpacing.m), - Expanded( - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.primary, - foregroundColor: theme.colorScheme.onPrimary, - ), - onPressed: () { - if (timerState.isRunning) { - ref.read(timerProvider.notifier).stop(); - } else { - ref.read(timerProvider.notifier).start(); - } - }, - child: Text(timerState.isRunning ? 'PAUSE' : 'START'), + const SizedBox(height: 8), + TimeDisplay( + formattedTime: TimeFormatter.formatChess(remainingTime), + fontSize: 68, + isFlagged: isFlagged, ), - ), - ], + if (isActive) + const Padding( + padding: EdgeInsets.only(top: 8.0), + child: Text( + 'TAP TO END MOVE', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, letterSpacing: 0.5), + ), + ), + ], + ), ), ); } + + // ========================================== + // CONTROLS CONFIGURATION + // ========================================== + + Widget _buildModeControls(TimerState timerState) { + final notifier = ref.read(timerProvider.notifier); + + VoidCallback? secondaryAction; + String? secondaryLabel; + IconData? secondaryIcon; + + if (timerState.mode == TimerMode.stopwatch) { + secondaryAction = () => notifier.recordStopwatchLap(); + secondaryLabel = 'LAP'; + secondaryIcon = Icons.flag_outlined; + } else if (timerState.mode == TimerMode.turn) { + secondaryAction = () => notifier.advanceTurn(); + secondaryLabel = 'NEXT'; + secondaryIcon = Icons.skip_next; + } + + return TimerControls( + isRunning: timerState.isRunning, + onStart: () => notifier.start(), + onPause: () => notifier.stop(), + onReset: () => notifier.reset(), + onSecondaryAction: secondaryAction, + secondaryActionLabel: secondaryLabel, + secondaryActionIcon: secondaryIcon, + ); + } } diff --git a/lib/features/timer/timer_state.dart b/lib/features/timer/timer_state.dart index 7fc1e6b..9e1ee0f 100644 --- a/lib/features/timer/timer_state.dart +++ b/lib/features/timer/timer_state.dart @@ -1,47 +1,194 @@ -enum TimerMode { countdown, stopwatch, turn, chess } +import 'package:flutter/foundation.dart'; +import 'domain/timer_models.dart'; +export 'domain/timer_models.dart'; + +/// Complete reactive state for the PlayMate Timer Suite across all 5 modes. +@immutable class TimerState { final TimerMode mode; - final Duration elapsed; - final Duration remaining; - final Duration initialDuration; final bool isRunning; - // Chess specific timer states + // --- Countdown State --- + final Duration countdownRemaining; + final Duration countdownInitial; + final bool countdownFinished; + + // --- Round / General Timer State --- + final RoundTimerConfig roundConfig; + final int currentRound; + final IntervalPhase intervalPhase; + final Duration roundRemaining; + final bool roundFinished; + + // --- Stopwatch State --- + final Duration stopwatchElapsed; + final List stopwatchLaps; + + // --- Turn Timer State --- + final List turnPlayers; + final int activePlayerIndex; + final Duration turnDuration; + final Duration turnRemaining; + final int currentTurnRound; + final bool turnExpired; + + // --- Chess Clock State --- final Duration chessPlayerARemaining; final Duration chessPlayerBRemaining; - final bool isPlayerATurn; // true = Player A active, false = Player B active + final Duration chessBaseTime; + final Duration chessIncrement; + final bool isPlayerATurn; + final int chessPlayerAMoves; + final int chessPlayerBMoves; + final bool chessPlayerAFlagged; + final bool chessPlayerBFlagged; + final String activePresetId; - TimerState({ + const TimerState({ required this.mode, - required this.elapsed, - required this.remaining, - required this.initialDuration, required this.isRunning, + // Countdown + required this.countdownRemaining, + required this.countdownInitial, + this.countdownFinished = false, + // Round Timer + this.roundConfig = const RoundTimerConfig(), + this.currentRound = 1, + this.intervalPhase = IntervalPhase.work, + required this.roundRemaining, + this.roundFinished = false, + // Stopwatch + required this.stopwatchElapsed, + this.stopwatchLaps = const [], + // Turn Timer + this.turnPlayers = const [ + TurnPlayer(id: 'p1', name: 'Player 1'), + TurnPlayer(id: 'p2', name: 'Player 2'), + TurnPlayer(id: 'p3', name: 'Player 3'), + TurnPlayer(id: 'p4', name: 'Player 4'), + ], + this.activePlayerIndex = 0, + this.turnDuration = const Duration(seconds: 60), + required this.turnRemaining, + this.currentTurnRound = 1, + this.turnExpired = false, + // Chess Clock required this.chessPlayerARemaining, required this.chessPlayerBRemaining, + this.chessBaseTime = const Duration(minutes: 5), + this.chessIncrement = Duration.zero, required this.isPlayerATurn, + this.chessPlayerAMoves = 0, + this.chessPlayerBMoves = 0, + this.chessPlayerAFlagged = false, + this.chessPlayerBFlagged = false, + this.activePresetId = 'blitz_5_0', }); + /// Factory for clean initial state + factory TimerState.initial() { + return const TimerState( + mode: TimerMode.countdown, + isRunning: false, + countdownRemaining: Duration(minutes: 5), + countdownInitial: Duration(minutes: 5), + roundRemaining: Duration(minutes: 3), + stopwatchElapsed: Duration.zero, + turnRemaining: Duration(seconds: 60), + chessPlayerARemaining: Duration(minutes: 5), + chessPlayerBRemaining: Duration(minutes: 5), + isPlayerATurn: true, + ); + } + + // --- Compatibility Getters for existing usages --- + Duration get remaining { + switch (mode) { + case TimerMode.countdown: + return countdownRemaining; + case TimerMode.timer: + return roundRemaining; + case TimerMode.stopwatch: + return Duration.zero; + case TimerMode.turn: + return turnRemaining; + case TimerMode.chess: + return isPlayerATurn ? chessPlayerARemaining : chessPlayerBRemaining; + } + } + + Duration get elapsed => stopwatchElapsed; + Duration get initialDuration => countdownInitial; + TimerState copyWith({ TimerMode? mode, - Duration? elapsed, - Duration? remaining, - Duration? initialDuration, bool? isRunning, + // Countdown + Duration? countdownRemaining, + Duration? countdownInitial, + bool? countdownFinished, + // Round Timer + RoundTimerConfig? roundConfig, + int? currentRound, + IntervalPhase? intervalPhase, + Duration? roundRemaining, + bool? roundFinished, + // Stopwatch + Duration? stopwatchElapsed, + List? stopwatchLaps, + // Turn Timer + List? turnPlayers, + int? activePlayerIndex, + Duration? turnDuration, + Duration? turnRemaining, + int? currentTurnRound, + bool? turnExpired, + // Chess Clock Duration? chessPlayerARemaining, Duration? chessPlayerBRemaining, + Duration? chessBaseTime, + Duration? chessIncrement, bool? isPlayerATurn, + int? chessPlayerAMoves, + int? chessPlayerBMoves, + bool? chessPlayerAFlagged, + bool? chessPlayerBFlagged, + String? activePresetId, + // Compat parameters + Duration? elapsed, + Duration? remaining, + Duration? initialDuration, }) { return TimerState( mode: mode ?? this.mode, - elapsed: elapsed ?? this.elapsed, - remaining: remaining ?? this.remaining, - initialDuration: initialDuration ?? this.initialDuration, isRunning: isRunning ?? this.isRunning, + countdownRemaining: countdownRemaining ?? remaining ?? this.countdownRemaining, + countdownInitial: countdownInitial ?? initialDuration ?? this.countdownInitial, + countdownFinished: countdownFinished ?? this.countdownFinished, + roundConfig: roundConfig ?? this.roundConfig, + currentRound: currentRound ?? this.currentRound, + intervalPhase: intervalPhase ?? this.intervalPhase, + roundRemaining: roundRemaining ?? this.roundRemaining, + roundFinished: roundFinished ?? this.roundFinished, + stopwatchElapsed: stopwatchElapsed ?? elapsed ?? this.stopwatchElapsed, + stopwatchLaps: stopwatchLaps ?? this.stopwatchLaps, + turnPlayers: turnPlayers ?? this.turnPlayers, + activePlayerIndex: activePlayerIndex ?? this.activePlayerIndex, + turnDuration: turnDuration ?? this.turnDuration, + turnRemaining: turnRemaining ?? this.turnRemaining, + currentTurnRound: currentTurnRound ?? this.currentTurnRound, + turnExpired: turnExpired ?? this.turnExpired, chessPlayerARemaining: chessPlayerARemaining ?? this.chessPlayerARemaining, chessPlayerBRemaining: chessPlayerBRemaining ?? this.chessPlayerBRemaining, + chessBaseTime: chessBaseTime ?? this.chessBaseTime, + chessIncrement: chessIncrement ?? this.chessIncrement, isPlayerATurn: isPlayerATurn ?? this.isPlayerATurn, + chessPlayerAMoves: chessPlayerAMoves ?? this.chessPlayerAMoves, + chessPlayerBMoves: chessPlayerBMoves ?? this.chessPlayerBMoves, + chessPlayerAFlagged: chessPlayerAFlagged ?? this.chessPlayerAFlagged, + chessPlayerBFlagged: chessPlayerBFlagged ?? this.chessPlayerBFlagged, + activePresetId: activePresetId ?? this.activePresetId, ); } } diff --git a/lib/features/timer/widgets/lap_list.dart b/lib/features/timer/widgets/lap_list.dart new file mode 100644 index 0000000..3684e1e --- /dev/null +++ b/lib/features/timer/widgets/lap_list.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import '../domain/timer_models.dart'; + +/// Renders recorded stopwatch laps in a compact, readable table. +class LapList extends StatelessWidget { + final List laps; + + const LapList({ + super.key, + required this.laps, + }); + + String _formatLapTime(Duration d) { + final minutes = (d.inMinutes % 60).toString().padLeft(2, '0'); + final seconds = (d.inSeconds % 60).toString().padLeft(2, '0'); + final centis = ((d.inMilliseconds % 1000) ~/ 10).toString().padLeft(2, '0'); + return '$minutes:$seconds.$centis'; + } + + @override + Widget build(BuildContext context) { + if (laps.isEmpty) return const SizedBox.shrink(); + + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + // Find fastest and slowest if 2 or more laps exist + int? fastestLapIndex; + int? slowestLapIndex; + + if (laps.length >= 2) { + Duration minSplit = laps.first.splitTime; + Duration maxSplit = laps.first.splitTime; + fastestLapIndex = 0; + slowestLapIndex = 0; + + for (int i = 0; i < laps.length; i++) { + if (laps[i].splitTime < minSplit) { + minSplit = laps[i].splitTime; + fastestLapIndex = i; + } + if (laps[i].splitTime > maxSplit) { + maxSplit = laps[i].splitTime; + slowestLapIndex = i; + } + } + } + + return Container( + constraints: const BoxConstraints(maxHeight: 220), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF7F7F7), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isDark ? const Color(0xFF2C2C2C) : const Color(0xFFE5E5E5), + ), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'LAP', + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + Text( + 'SPLIT', + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + Text( + 'TOTAL', + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: laps.length, + itemBuilder: (context, index) { + final lap = laps[index]; + final isFastest = fastestLapIndex == index; + final isSlowest = slowestLapIndex == index; + + Color? splitColor; + if (isFastest) splitColor = Colors.greenAccent.shade400; + if (isSlowest) splitColor = Colors.orangeAccent.shade400; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Lap ${lap.lapNumber.toString().padLeft(2, '0')}', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + ), + ), + Text( + _formatLapTime(lap.splitTime), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: splitColor, + ), + ), + Text( + _formatLapTime(lap.totalTime), + style: TextStyle( + fontSize: 13, + fontFamily: 'monospace', + color: theme.colorScheme.secondary, + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/timer/widgets/time_display.dart b/lib/features/timer/widgets/time_display.dart new file mode 100644 index 0000000..09d45fa --- /dev/null +++ b/lib/features/timer/widgets/time_display.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +/// Primary digital/tactile time display using tabular monospace figures to prevent layout jitter. +class TimeDisplay extends StatelessWidget { + final String formattedTime; + final String? subtitle; + final double fontSize; + final Color? color; + final bool isFlagged; + + const TimeDisplay({ + super.key, + required this.formattedTime, + this.subtitle, + this.fontSize = 72.0, + this.color, + this.isFlagged = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final displayColor = isFlagged + ? Colors.redAccent + : (color ?? theme.colorScheme.onSurface); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + formattedTime, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w900, + fontFamily: 'monospace', + letterSpacing: -1.5, + color: displayColor, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ), + if (subtitle != null && subtitle!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + subtitle!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.secondary, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ], + ], + ); + } +} + diff --git a/lib/features/timer/widgets/timer_controls.dart b/lib/features/timer/widgets/timer_controls.dart new file mode 100644 index 0000000..85c14a4 --- /dev/null +++ b/lib/features/timer/widgets/timer_controls.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import '../../../shared/responsive_layout.dart'; + +/// Primary physical-feeling bottom controls for timer suite. +class TimerControls extends StatelessWidget { + final bool isRunning; + final VoidCallback onStart; + final VoidCallback onPause; + final VoidCallback onReset; + final VoidCallback? onSecondaryAction; + final String? secondaryActionLabel; + final IconData? secondaryActionIcon; + + const TimerControls({ + super.key, + required this.isRunning, + required this.onStart, + required this.onPause, + required this.onReset, + this.onSecondaryAction, + this.secondaryActionLabel, + this.secondaryActionIcon, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.m, vertical: 12), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + top: BorderSide( + color: isDark ? const Color(0xFF2C2C2C) : const Color(0xFFE5E5E5), + width: 1.0, + ), + ), + ), + child: SafeArea( + top: false, + child: Row( + children: [ + // Reset Button + Expanded( + flex: 2, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 50), + side: BorderSide( + color: isDark ? const Color(0xFF383838) : const Color(0xFFD0D0D0), + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: onReset, + child: const Text( + 'RESET', + style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 0.5), + ), + ), + ), + const SizedBox(width: 8), + + // Optional Secondary Action (e.g., LAP or NEXT PLAYER) + if (onSecondaryAction != null && secondaryActionLabel != null) ...[ + Expanded( + flex: 3, + child: FilledButton.tonal( + style: FilledButton.styleFrom( + minimumSize: const Size(0, 50), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: isRunning ? onSecondaryAction : null, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (secondaryActionIcon != null) ...[ + Icon(secondaryActionIcon, size: 16), + const SizedBox(width: 4), + ], + Flexible( + child: Text( + secondaryActionLabel!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + ], + + // Primary Start / Pause Button + Expanded( + flex: 3, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + minimumSize: const Size(0, 50), + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: isRunning ? onPause : onStart, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(isRunning ? Icons.pause : Icons.play_arrow, size: 20), + const SizedBox(width: 6), + Text( + isRunning ? 'PAUSE' : 'START', + style: const TextStyle(fontWeight: FontWeight.bold, letterSpacing: 0.8), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/timer/widgets/timer_mode_selector.dart b/lib/features/timer/widgets/timer_mode_selector.dart new file mode 100644 index 0000000..6e4ac4f --- /dev/null +++ b/lib/features/timer/widgets/timer_mode_selector.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import '../../../shared/responsive_layout.dart'; +import '../domain/timer_models.dart'; + +/// Segmented responsive control for selecting between the 5 timer modes. +class TimerModeSelector extends StatelessWidget { + final TimerMode selectedMode; + final ValueChanged onModeChanged; + + const TimerModeSelector({ + super.key, + required this.selectedMode, + required this.onModeChanged, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.m, vertical: 8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: TimerMode.values.map((mode) { + final isSelected = selectedMode == mode; + return Padding( + padding: const EdgeInsets.only(right: 6.0), + child: FilterChip( + selected: isSelected, + label: Text( + mode.displayName, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, + color: isSelected + ? (isDark ? Colors.black : Colors.white) + : (isDark ? Colors.white70 : Colors.black87), + ), + ), + selectedColor: isDark ? const Color(0xFFC18A42) : const Color(0xFF1A1A1A), + backgroundColor: isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF0F0F0), + showCheckmark: false, + side: BorderSide( + color: isSelected + ? (isDark ? const Color(0xFFC18A42) : const Color(0xFF1A1A1A)) + : (isDark ? const Color(0xFF2C2C2C) : const Color(0xFFE0E0E0)), + width: 1.0, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + onSelected: (_) => onModeChanged(mode), + ), + ); + }).toList(), + ), + ); + } +} diff --git a/pravacy-policy.html b/pravacy-policy.html new file mode 100644 index 0000000..e69de29 diff --git a/pubspec.yaml b/pubspec.yaml index e03d9fb..b435db7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: 1.1.0+2 environment: sdk: ^3.11.4 @@ -73,21 +73,21 @@ dev_dependencies: flutter_launcher_icons: android: true ios: true - image_path: "assets/icons/app_icon.png" + image_path: "assets/icons/playmate_logo.png" min_sdk_android: 21 adaptive_icon_background: "#1A1A1A" - adaptive_icon_foreground: "assets/icons/app_icon.png" + adaptive_icon_foreground: "assets/icons/playmate_logo.png" flutter_native_splash: color: "#FFFFFF" - image: "assets/icons/app_icon.png" + image: "assets/icons/playmate_logo.png" color_dark: "#121212" - image_dark: "assets/icons/app_icon.png" + image_dark: "assets/icons/playmate_logo.png" android_12: color: "#FFFFFF" - image: "assets/icons/app_icon.png" + image: "assets/icons/playmate_logo.png" color_dark: "#121212" - image_dark: "assets/icons/app_icon.png" + image_dark: "assets/icons/playmate_logo.png" # The following section is specific to Flutter packages. flutter: diff --git a/test/about_flow_test.dart b/test/about_flow_test.dart index aa362e4..69fd185 100644 --- a/test/about_flow_test.dart +++ b/test/about_flow_test.dart @@ -27,7 +27,7 @@ void main() { testWidgets('AboutScreen renders dynamic package info and developer details', (WidgetTester tester) async { PackageInfo.setMockInitialValues( appName: 'PlayMate', - packageName: 'com.example.playmate', + packageName: 'com.sundramdotdev.playmate', version: '1.0.0', buildNumber: '1', buildSignature: '', @@ -39,7 +39,7 @@ void main() { packageInfoProvider.overrideWith( (ref) => PackageInfo( appName: 'PlayMate', - packageName: 'com.example.playmate', + packageName: 'com.sundramdotdev.playmate', version: '1.0.0', buildNumber: '1', buildSignature: '', @@ -58,12 +58,14 @@ void main() { expect(find.text('PlayMate'), findsWidgets); expect(find.text('Everything you need for offline games.'), findsOneWidget); expect(find.text('Sundramdotdev'), findsOneWidget); + expect(find.text('Official Website'), findsOneWidget); expect(find.text('GitHub'), findsOneWidget); expect(find.text('LinkedIn'), findsOneWidget); + expect(find.text('PlayMate Repository'), findsOneWidget); expect(find.text('Instagram'), findsOneWidget); expect(find.text('Privacy Policy'), findsOneWidget); expect(find.text('Terms & Conditions'), findsOneWidget); - expect(find.text('What\'s New'), findsOneWidget); + expect(find.text('What\'s New in 1.1.0'), findsOneWidget); }); testWidgets('PrivacyPolicyScreen renders and scrolls without error', (WidgetTester tester) async { diff --git a/test/coin_system_test.dart b/test/coin_system_test.dart new file mode 100644 index 0000000..5ed5bb1 --- /dev/null +++ b/test/coin_system_test.dart @@ -0,0 +1,217 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:playmate/core/theme.dart'; +import 'package:playmate/features/coin/coin_face.dart'; +import 'package:playmate/features/coin/coin_orientation.dart'; +import 'package:playmate/features/coin/coin_provider.dart'; +import 'package:playmate/features/coin/coin_screen.dart'; +import 'package:playmate/features/coin/playmate_coin.dart'; + +void main() { + setUpAll(() async { + Hive.init('test_hive_coin'); + await Hive.openBox('settings'); + await Hive.openBox('matches'); + await Hive.openBox('achievements'); + await Hive.openBox('statistics'); + }); + + tearDownAll(() async { + await Hive.close(); + }); + + group('Coin Orientation & Geometry Mathematics', () { + test('Canonical proportions maintain 1:8 thickness ratio', () { + expect(CoinOrientation.thicknessRatio, closeTo(0.12, 0.01)); + expect(CoinOrientation.bevelRatio, closeTo(0.04, 0.01)); + }); + + test('Base angles align Heads to 0 and Tails to pi', () { + final headsAngle = CoinOrientation.getBaseAngleForSide(CoinSide.heads); + final tailsAngle = CoinOrientation.getBaseAngleForSide(CoinSide.tails); + + expect(headsAngle, equals(0.0)); + expect(tailsAngle, equals(math.pi)); + + expect(CoinOrientation.isHeadsFacing(headsAngle), isTrue); + expect(CoinOrientation.isHeadsFacing(tailsAngle), isFalse); + }); + + test('calculateTargetFlipAngle lands deterministically on target side for any cycle count', () { + for (final targetSide in CoinSide.values) { + for (int cycles = 3; cycles <= 8; cycles++) { + final targetAngle = CoinOrientation.calculateTargetFlipAngle( + currentRx: 0.0, + targetSide: targetSide, + fullCycles: cycles, + ); + + // Verify cosine matches target side + final double cosVal = math.cos(targetAngle); + if (targetSide == CoinSide.heads) { + expect(cosVal, closeTo(1.0, 0.001), reason: 'Heads targetAngle must have cos=1'); + expect(CoinOrientation.isHeadsFacing(targetAngle), isTrue); + } else { + expect(cosVal, closeTo(-1.0, 0.001), reason: 'Tails targetAngle must have cos=-1'); + expect(CoinOrientation.isHeadsFacing(targetAngle), isFalse); + } + } + } + }); + + test('Edge visibility reaches 1.0 when edge-on at 90 degrees', () { + expect(CoinOrientation.getEdgeVisibility(0.0), closeTo(0.0, 0.001)); + expect(CoinOrientation.getEdgeVisibility(math.pi / 2), closeTo(1.0, 0.001)); + expect(CoinOrientation.getEdgeVisibility(math.pi), closeTo(0.0, 0.001)); + }); + }); + + group('Coin Logic & Riverpod Notifier', () { + test('Initial coin state has Heads and empty history', () { + final container = ProviderContainer(); + final state = container.read(coinProvider); + + expect(state.lastResult, equals(CoinSide.heads)); + expect(state.isFlipping, isFalse); + expect(state.tossHistory, isEmpty); + expect(state.headsCount, equals(0)); + expect(state.tailsCount, equals(0)); + expect(state.totalTosses, equals(0)); + }); + + test('Reset coin restores Heads without deleting history', () { + final container = ProviderContainer(); + container.read(coinProvider.notifier).resetCoin(); + final state = container.read(coinProvider); + + expect(state.lastResult, equals(CoinSide.heads)); + }); + + test('Toss updates state to target side and records history after completion', () async { + final container = ProviderContainer(); + final future = container.read(coinProvider.notifier).toss(); + + // Immediately becomes isFlipping: true with non-null target result + final flippingState = container.read(coinProvider); + expect(flippingState.isFlipping, isTrue); + expect(flippingState.lastResult, isNotNull); + + // Duplicate toss while flipping is rejected + await container.read(coinProvider.notifier).toss(); + + // Await toss completion + await future; + + final settledState = container.read(coinProvider); + expect(settledState.isFlipping, isFalse); + expect(settledState.tossHistory.length, equals(1)); + expect(settledState.totalTosses, equals(1)); + expect(settledState.tossHistory.first, equals(settledState.lastResult)); + + if (settledState.lastResult == CoinSide.heads) { + expect(settledState.headsCount, equals(1)); + expect(settledState.tailsCount, equals(0)); + } else { + expect(settledState.headsCount, equals(0)); + expect(settledState.tailsCount, equals(1)); + } + }); + + test('Clear history clears toss history and resets counts', () { + final container = ProviderContainer(); + container.read(coinProvider.notifier).clearHistory(); + final state = container.read(coinProvider); + + expect(state.tossHistory, isEmpty); + expect(state.totalTosses, equals(0)); + }); + }); + + group('Coin UI Widgets & Semantics', () { + testWidgets('CoinFace renders both HEADS and TAILS with PlayMate branding', (tester) async { + for (final side in CoinSide.values) { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: CoinFace( + side: side, + size: 150, + isDarkMode: false, + ), + ), + ), + ), + ); + + expect(find.byType(CoinFace), findsOneWidget); + } + }); + + testWidgets('PlayMateCoin renders tactile 3D coin with semantics', (tester) async { + bool tossRequested = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PlayMateCoin( + side: CoinSide.heads, + size: 150, + onTossRequested: () { + tossRequested = true; + }, + ), + ), + ), + ), + ); + + expect(find.byType(PlayMateCoin), findsOneWidget); + + // Tap on coin to trigger toss + await tester.tap(find.byType(PlayMateCoin)); + await tester.pump(); + expect(tossRequested, isTrue); + }); + }); + + group('Responsive Layout Testing', () { + final testWidths = [320.0, 360.0, 390.0, 412.0, 768.0, 1024.0]; + + for (final width in testWidths) { + testWidgets('CoinScreen renders without overflow at ${width}px width', (tester) async { + tester.view.physicalSize = Size(width, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + FlutterErrorDetails? caughtDetails; + FlutterError.onError = (details) { + caughtDetails = details; + }; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.lightTheme, + home: const CoinScreen(), + ), + ), + ); + + await tester.pump(); + + // Verify key components rendered + expect(find.byType(CoinScreen), findsOneWidget); + expect(find.byType(PlayMateCoin), findsOneWidget); + expect(find.text('TOSS COIN'), findsOneWidget); + + // Verify zero RenderFlex overflows + expect(caughtDetails, isNull); + }); + } + }); +} diff --git a/test/dice_system_test.dart b/test/dice_system_test.dart new file mode 100644 index 0000000..1291fc9 --- /dev/null +++ b/test/dice_system_test.dart @@ -0,0 +1,265 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:playmate/core/theme.dart'; +import 'package:playmate/features/dice/dice_orientation.dart'; +import 'package:playmate/features/dice/dice_pips.dart'; +import 'package:playmate/features/dice/dice_provider.dart'; +import 'package:playmate/features/dice/dice_roller_view.dart'; +import 'package:playmate/features/dice/dice_screen.dart'; +import 'package:playmate/features/dice/dice_state.dart'; +import 'package:playmate/features/dice/playmate_dice.dart'; + +void main() { + setUpAll(() async { + Hive.init('test_hive_dice'); + await Hive.openBox('settings'); + await Hive.openBox('matches'); + await Hive.openBox('achievements'); + await Hive.openBox('statistics'); + }); + + tearDownAll(() async { + await Hive.close(); + }); + + group('Dice Orientation & Mathematics', () { + test('All 6 canonical faces have sum of opposite faces equal to 7', () { + final faces = DiceFaceOrientation.canonicalFaces; + expect(faces.length, equals(6)); + + // 1 (Front) + 6 (Back) = 7 + final f1 = faces.firstWhere((f) => f.value == 1); + final f6 = faces.firstWhere((f) => f.value == 6); + expect(f1.value + f6.value, equals(7)); + expect(f1.localNormal.z, equals(-1.0)); + expect(f6.localNormal.z, equals(1.0)); + + // 2 (Top) + 5 (Bottom) = 7 + final f2 = faces.firstWhere((f) => f.value == 2); + final f5 = faces.firstWhere((f) => f.value == 5); + expect(f2.value + f5.value, equals(7)); + expect(f2.localNormal.y, equals(-1.0)); + expect(f5.localNormal.y, equals(1.0)); + + // 3 (Right) + 4 (Left) = 7 + final f3 = faces.firstWhere((f) => f.value == 3); + final f4 = faces.firstWhere((f) => f.value == 4); + expect(f3.value + f4.value, equals(7)); + expect(f3.localNormal.x, equals(1.0)); + expect(f4.localNormal.x, equals(-1.0)); + }); + + test('For every value 1 to 6, base rotation points the face normal at camera (0, 0, -1)', () { + for (int val = 1; val <= 6; val++) { + final rot = DiceFaceOrientation.getBaseEulerForValue(val); + final rotMatrix = Matrix4.identity() + ..rotateX(rot.x) + ..rotateY(rot.y) + ..rotateZ(rot.z); + + final targetFace = + DiceFaceOrientation.canonicalFaces.firstWhere((f) => f.value == val); + final s = rotMatrix.storage; + final nx = targetFace.localNormal.x; + final ny = targetFace.localNormal.y; + final nz = targetFace.localNormal.z; + + final worldZ = s[2] * nx + s[6] * ny + s[10] * nz; + final worldX = s[0] * nx + s[4] * ny + s[8] * nz; + final worldY = s[1] * nx + s[5] * ny + s[9] * nz; + + // Facing camera directly means normal Z is close to -1.0, X and Y close to 0.0 + expect(worldZ, closeTo(-1.0, 0.001), reason: 'Value $val must face camera'); + expect(worldX, closeTo(0.0, 0.001), reason: 'Value $val must have worldX=0'); + expect(worldY, closeTo(0.0, 0.001), reason: 'Value $val must have worldY=0'); + } + }); + + test('computeVisibleFaces returns at most 3 visible faces in depth order', () { + for (int val = 1; val <= 6; val++) { + final restingEuler = DiceFaceOrientation.getRestingEulerForValue(val); + final visible = DiceFaceOrientation.computeVisibleFaces( + cubeRotation: restingEuler, + halfExtent: 50.0, + ); + + expect(visible.length, inInclusiveRange(1, 3)); + // The winning face must always be in the visible set + expect(visible.any((f) => f.value == val), isTrue); + + // Painter's algorithm verification: elements sorted from greatest depthZ to least + for (int i = 0; i < visible.length - 1; i++) { + expect(visible[i].depthZ >= visible[i + 1].depthZ, isTrue); + } + } + }); + }); + + group('Dice Logic & Riverpod Notifier', () { + test('Initial dice roll state has 1 die showing 6', () { + final container = ProviderContainer(); + final state = container.read(diceProvider); + + expect(state.diceCount, equals(1)); + expect(state.currentRolls, equals([6])); + expect(state.isRolling, isFalse); + expect(state.totalSum, equals(6)); + expect(state.diceType, equals(DiceType.d6)); + }); + + test('Changing dice count updates currentRolls and totalSum', () { + final container = ProviderContainer(); + container.read(diceProvider.notifier).setDiceCount(3); + final state = container.read(diceProvider); + + expect(state.diceCount, equals(3)); + expect(state.currentRolls.length, equals(3)); + expect(state.totalSum, equals(18)); + }); + + test('Resetting dice restores initial sides', () { + final container = ProviderContainer(); + container.read(diceProvider.notifier).setDiceCount(2); + container.read(diceProvider.notifier).resetDice(); + final state = container.read(diceProvider); + + expect(state.currentRolls, equals([6, 6])); + expect(state.totalSum, equals(12)); + }); + + test('Roll updates state to target rolls and records history after completion', () async { + final container = ProviderContainer(); + final future = container.read(diceProvider.notifier).roll(); + + // Immediately becomes isRolling: true with valid target rolls 1..6 + final rollingState = container.read(diceProvider); + expect(rollingState.isRolling, isTrue); + expect(rollingState.currentRolls.length, equals(1)); + expect(rollingState.currentRolls.first, inInclusiveRange(1, 6)); + + // Duplicate roll while rolling is rejected + await container.read(diceProvider.notifier).roll(); + + // Await roll completion + await future; + + final settledState = container.read(diceProvider); + expect(settledState.isRolling, isFalse); + expect(settledState.rollHistory.isNotEmpty, isTrue); + expect(settledState.rollHistory.first, equals(settledState.currentRolls)); + }); + }); + + group('Dice UI Widgets & Semantics', () { + testWidgets('DicePips renders correct pip counts and PlayMate brand mark', (tester) async { + for (int val = 1; val <= 6; val++) { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: DicePips( + value: val, + faceSize: 100, + pipColor: const Color(0xFF17181A), + ), + ), + ), + ), + ); + + expect(find.byType(DicePips), findsOneWidget); + expect(DicePips.normalizedPipOffsets[val]!.length, equals(val == 1 ? 1 : val)); + } + }); + + testWidgets('PlayMateDice renders tactile 3D die with semantics', (tester) async { + bool rollRequested = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PlayMateDice( + value: 4, + size: 140, + onRollRequested: () { + rollRequested = true; + }, + ), + ), + ), + ), + ); + + // Verify die widget is mounted + expect(find.byType(PlayMateDice), findsOneWidget); + + // Tap on die to trigger roll + await tester.tap(find.byType(PlayMateDice)); + await tester.pump(); + expect(rollRequested, isTrue); + }); + + testWidgets('DiceRollerView coordinates multi-dice layout (4 dice)', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 360, + height: 400, + child: DiceRollerView( + rolls: const [2, 4, 5, 6], + diceType: DiceType.d6, + isRolling: false, + onRollRequested: () {}, + ), + ), + ), + ), + ), + ); + + expect(find.byType(PlayMateDice), findsNWidgets(4)); + }); + }); + + group('Responsive Layout Testing', () { + final testWidths = [320.0, 360.0, 390.0, 412.0, 768.0]; + + for (final width in testWidths) { + testWidgets('DiceScreen renders without overflow at ${width}px width', (tester) async { + tester.view.physicalSize = Size(width, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + FlutterErrorDetails? caughtDetails; + FlutterError.onError = (details) { + caughtDetails = details; + }; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.lightTheme, + home: const DiceScreen(), + ), + ), + ); + + await tester.pump(); + + // Verify key components rendered + expect(find.byType(DiceScreen), findsOneWidget); + expect(find.byType(DiceRollerView), findsOneWidget); + expect(find.text('ROLL DICE'), findsOneWidget); + expect(find.text('Dice Quantity'), findsOneWidget); + + // Verify zero RenderFlex overflows + expect(caughtDetails, isNull); + }); + } + }); +} diff --git a/test/spin_wheel_system_test.dart b/test/spin_wheel_system_test.dart new file mode 100644 index 0000000..8df275a --- /dev/null +++ b/test/spin_wheel_system_test.dart @@ -0,0 +1,281 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:playmate/core/theme.dart'; +import 'package:playmate/features/spin_wheel/playmate_spin_wheel.dart'; +import 'package:playmate/features/spin_wheel/spin_wheel_math.dart'; +import 'package:playmate/features/spin_wheel/spin_wheel_models.dart'; +import 'package:playmate/features/spin_wheel/spin_wheel_provider.dart'; +import 'package:playmate/features/spin_wheel/spin_wheel_screen.dart'; +import 'package:playmate/features/spin_wheel/wheel_pointer.dart'; + +void main() { + setUpAll(() async { + Hive.init('test_hive_spin_wheel'); + await Hive.openBox('settings'); + await Hive.openBox('matches'); + await Hive.openBox('achievements'); + await Hive.openBox('statistics'); + }); + + tearDownAll(() async { + await Hive.close(); + }); + + group('Spin Wheel Mathematics & Determinism', () { + test('getSweepAngle accurately divides full circle by segment count', () { + expect(SpinWheelMath.getSweepAngle(4), closeTo(math.pi / 2, 0.0001)); + expect(SpinWheelMath.getSweepAngle(6), closeTo(math.pi / 3, 0.0001)); + expect(SpinWheelMath.getSweepAngle(8), closeTo(math.pi / 4, 0.0001)); + }); + + test('calculateTargetRotation places winning sector center exactly at Top pointer for all N', () { + for (int count = 2; count <= 12; count++) { + for (int winningIndex = 0; winningIndex < count; winningIndex++) { + final target = SpinWheelMath.calculateTargetRotation( + currentRotation: 0.0, + winningIndex: winningIndex, + count: count, + revolutions: 6, + ); + + // Sector center angle on unrotated wheel: -pi/2 + (winningIndex + 0.5) * sweep + // When rotated by target, the position of that sector center in screen space is: + // centerAngle + target + final sweep = SpinWheelMath.getSweepAngle(count); + final centerAngle = -math.pi / 2 + (winningIndex + 0.5) * sweep; + final finalScreenAngle = (centerAngle + target) % (2 * math.pi); + + // Pointer is at -pi/2, which is 3*pi/2 mod 2pi + final expectedPointer = (-math.pi / 2) % (2 * math.pi); + final diff = (finalScreenAngle - expectedPointer).abs() % (2 * math.pi); + final shortestDist = math.min(diff, 2 * math.pi - diff); + + expect( + shortestDist, + lessThan(0.0001), + reason: 'Failed for count=$count, winningIndex=$winningIndex', + ); + } + } + }); + + test('getIndexUnderPointer accurately retrieves sector under pointer for any rotation', () { + const count = 4; + for (int i = 0; i < count; i++) { + final rot = SpinWheelMath.calculateTargetRotation( + currentRotation: 0.0, + winningIndex: i, + count: count, + revolutions: 5, + ); + final detected = SpinWheelMath.getIndexUnderPointer(rotation: rot, count: count); + expect(detected, equals(i)); + } + }); + + test('Curated palettes guarantee contrast for odd and even segment counts', () { + for (int count = 2; count <= 10; count++) { + for (int i = 0; i < count; i++) { + final lightColor = SpinWheelMath.getSegmentColor(i, count, false); + final darkColor = SpinWheelMath.getSegmentColor(i, count, true); + expect(lightColor, isNotNull); + expect(darkColor, isNotNull); + + final lightTextColor = SpinWheelMath.getTextColorForSegment(lightColor, false); + final darkTextColor = SpinWheelMath.getTextColorForSegment(darkColor, true); + expect(lightTextColor, isNotNull); + expect(darkTextColor, isNotNull); + } + } + }); + }); + + group('Spin Wheel Riverpod Notifier & State', () { + test('Initial state loads Truth or Dare preset with at least 4 options', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final state = container.read(spinWheelProvider); + expect(state.options.length, greaterThanOrEqualTo(4)); + expect(state.isSpinning, isFalse); + expect(state.selectedWinner, isNull); + }); + + test('Add, update, and remove options maintain minimum 2 options constraint', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final notifier = container.read(spinWheelProvider.notifier); + + // Add option + notifier.addOption('New Option'); + expect(container.read(spinWheelProvider).options.any((o) => o.label == 'New Option'), isTrue); + + // Update option + final opt = container.read(spinWheelProvider).options.firstWhere((o) => o.label == 'New Option'); + notifier.updateOption(opt.id, 'Updated Option'); + expect(container.read(spinWheelProvider).options.any((o) => o.label == 'Updated Option'), isTrue); + + // Attempt to remove down to below 2 options + while (container.read(spinWheelProvider).options.length > 2) { + notifier.removeOptionById(container.read(spinWheelProvider).options.first.id); + } + expect(container.read(spinWheelProvider).options.length, equals(2)); + + // Cannot remove below 2 + final remainingId = container.read(spinWheelProvider).options.first.id; + notifier.removeOptionById(remainingId); + expect(container.read(spinWheelProvider).options.length, equals(2)); + }); + + test('Apply preset switches options and resets winner', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final notifier = container.read(spinWheelProvider.notifier); + final foodPreset = SpinWheelPreset.builtInPresets.firstWhere((p) => p.id == 'food_picker'); + + notifier.applyPreset(foodPreset); + final state = container.read(spinWheelProvider); + expect(state.activePresetId, equals('food_picker')); + expect(state.options.length, equals(6)); + expect(state.options.first.label, equals('Pizza')); + }); + + test('Deterministic spin commits winner and updates history', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final notifier = container.read(spinWheelProvider.notifier); + notifier.clearHistory(); + final spinFuture = notifier.spin(); + + // State is immediately marked isSpinning with a pre-selected winner + expect(container.read(spinWheelProvider).isSpinning, isTrue); + expect(container.read(spinWheelProvider).selectedWinner, isNotNull); + + await spinFuture; + + final settledState = container.read(spinWheelProvider); + expect(settledState.isSpinning, isFalse); + expect(settledState.selectedWinner, isNotNull); + expect(settledState.history.length, equals(1)); + expect(settledState.history.first.winnerLabel, equals(settledState.selectedWinner!.label)); + }); + + test('Remove winner eliminates current winner and clears winner selection', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final notifier = container.read(spinWheelProvider.notifier); + await notifier.spin(); + + final winner = container.read(spinWheelProvider).selectedWinner!; + final initialCount = container.read(spinWheelProvider).options.length; + + notifier.removeWinner(); + + final state = container.read(spinWheelProvider); + expect(state.options.length, equals(initialCount - 1)); + expect(state.options.any((o) => o.id == winner.id), isFalse); + expect(state.selectedWinner, isNull); + }); + }); + + group('Spin Wheel UI Components & Painting', () { + testWidgets('WheelPointer renders without errors in light and dark mode', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: WheelPointer(deflectionAngle: 0.05, isDarkMode: false), + ), + ), + ), + ); + expect(find.byType(WheelPointer), findsOneWidget); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: WheelPointer(deflectionAngle: -0.05, isDarkMode: true), + ), + ), + ), + ); + expect(find.byType(WheelPointer), findsOneWidget); + }); + + testWidgets('PlayMateSpinWheel renders hero wheel with custom painter and pointer', (tester) async { + final options = [ + const WheelOption(id: '1', label: 'One'), + const WheelOption(id: '2', label: 'Two'), + const WheelOption(id: '3', label: 'Three'), + ]; + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.lightTheme, + home: Scaffold( + body: Center( + child: PlayMateSpinWheel( + options: options, + size: 260.0, + ), + ), + ), + ), + ); + + expect(find.byType(PlayMateSpinWheel), findsOneWidget); + expect(find.byType(WheelPointer), findsOneWidget); + expect(find.byType(CustomPaint), findsWidgets); + }); + + testWidgets('SpinWheelScreen renders full interface with controls and zero overflow', (tester) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.lightTheme, + home: const SpinWheelScreen(), + ), + ), + ); + + expect(find.text('Spin Wheel'), findsOneWidget); + expect(find.text('SPIN WHEEL'), findsOneWidget); + expect(find.byType(PlayMateSpinWheel), findsOneWidget); + }); + }); + + group('Responsive Layout Testing Across Screen Widths', () { + final testWidths = [320.0, 360.0, 390.0, 412.0, 768.0, 1024.0]; + + for (final width in testWidths) { + testWidgets('SpinWheelScreen renders without overflow at ${width}px width', (tester) async { + tester.view.physicalSize = Size(width, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.lightTheme, + home: const SpinWheelScreen(), + ), + ), + ); + + await tester.pump(); + expect(tester.takeException(), isNull, reason: 'No exception or overflow at width $width'); + expect(find.text('Spin Wheel'), findsOneWidget); + expect(find.byType(PlayMateSpinWheel), findsOneWidget); + }); + } + }); +} diff --git a/test/timer_system_test.dart b/test/timer_system_test.dart new file mode 100644 index 0000000..3ab138c --- /dev/null +++ b/test/timer_system_test.dart @@ -0,0 +1,356 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:playmate/core/theme.dart'; +import 'package:playmate/features/timer/domain/time_formatter.dart'; +import 'package:playmate/features/timer/domain/time_source.dart'; +import 'package:playmate/services/storage_service.dart'; +import 'package:playmate/features/timer/timer_provider.dart'; +import 'package:playmate/features/timer/timer_screen.dart'; +import 'package:playmate/features/timer/timer_state.dart'; +import 'package:playmate/features/timer/widgets/lap_list.dart'; +import 'package:playmate/features/timer/widgets/time_display.dart'; +import 'package:playmate/features/timer/widgets/timer_controls.dart'; +import 'package:playmate/features/timer/widgets/timer_mode_selector.dart'; + +void main() { + setUpAll(() async { + Hive.init('test_hive_timer'); + await Hive.openBox('settings'); + await Hive.openBox('matches'); + await Hive.openBox('achievements'); + await Hive.openBox('statistics'); + }); + + tearDownAll(() async { + await Hive.close(); + }); + + group('TimeFormatter Unit Tests', () { + test('formatMmSs formats MM:SS and HH:MM:SS accurately', () { + expect(TimeFormatter.formatMmSs(Duration.zero), equals('00:00')); + expect(TimeFormatter.formatMmSs(const Duration(seconds: 45)), equals('00:45')); + expect(TimeFormatter.formatMmSs(const Duration(minutes: 5, seconds: 9)), equals('05:09')); + expect(TimeFormatter.formatMmSs(const Duration(hours: 1, minutes: 2, seconds: 3)), equals('1:02:03')); + }); + + test('formatStopwatch includes centiseconds', () { + expect( + TimeFormatter.formatStopwatch(const Duration(minutes: 1, seconds: 23, milliseconds: 450)), + equals('01:23.45'), + ); + }); + + test('formatChess shows tenths when under 10 seconds', () { + expect( + TimeFormatter.formatChess(const Duration(seconds: 7, milliseconds: 400)), + equals('7.4s'), + ); + expect( + TimeFormatter.formatChess(const Duration(minutes: 2)), + equals('02:00'), + ); + }); + }); + + group('Monotonic Timing Engine & Notifier', () { + late TestTimeSource timeSource; + late TimerNotifier notifier; + + setUp(() { + if (StorageService.isBoxOpen(StorageService.settingsBoxName)) { + StorageService.getSettingsBox().delete('timer_countdown_seconds'); + StorageService.getSettingsBox().delete('timer_chess_base_seconds'); + StorageService.getSettingsBox().delete('timer_chess_inc_seconds'); + StorageService.getSettingsBox().delete('timer_chess_preset'); + } + timeSource = TestTimeSource(1000000); // 1 sec base + notifier = TimerNotifier(timeSource: timeSource); + }); + + tearDown(() { + notifier.dispose(); + }); + + test('Initial mode is Countdown at 5 minutes', () { + expect(notifier.state.mode, equals(TimerMode.countdown)); + expect(notifier.state.countdownRemaining, equals(const Duration(minutes: 5))); + expect(notifier.state.isRunning, isFalse); + }); + + test('Countdown: Start, pause, resume does not lose accumulated time', () { + notifier.configureCountdown(const Duration(minutes: 1)); + notifier.start(); + expect(notifier.state.isRunning, isTrue); + + // Advance clock by 15 seconds + timeSource.advance(const Duration(seconds: 15)); + notifier.stop(); + + expect(notifier.state.isRunning, isFalse); + expect(notifier.state.countdownRemaining, equals(const Duration(seconds: 45))); + + // Resume and advance another 10 seconds + notifier.start(); + timeSource.advance(const Duration(seconds: 10)); + notifier.stop(); + + expect(notifier.state.countdownRemaining, equals(const Duration(seconds: 35))); + }); + + test('Countdown: Reset restores initial duration', () { + notifier.configureCountdown(const Duration(minutes: 2)); + notifier.start(); + timeSource.advance(const Duration(seconds: 30)); + notifier.stop(); + + expect(notifier.state.countdownRemaining, equals(const Duration(seconds: 90))); + notifier.reset(); + expect(notifier.state.countdownRemaining, equals(const Duration(minutes: 2))); + }); + + test('Stopwatch: Elapsed time accumulation and lap recording', () { + notifier.setMode(TimerMode.stopwatch); + notifier.start(); + + timeSource.advance(const Duration(seconds: 10, milliseconds: 500)); + notifier.stop(); + expect(notifier.state.stopwatchElapsed, equals(const Duration(seconds: 10, milliseconds: 500))); + + // Record lap while running + notifier.start(); + timeSource.advance(const Duration(seconds: 5)); + notifier.recordStopwatchLap(); + + expect(notifier.state.stopwatchLaps.length, equals(1)); + expect(notifier.state.stopwatchLaps.first.lapNumber, equals(1)); + expect(notifier.state.stopwatchLaps.first.totalTime, equals(const Duration(seconds: 15, milliseconds: 500))); + expect(notifier.state.stopwatchLaps.first.splitTime, equals(const Duration(seconds: 15, milliseconds: 500))); + + timeSource.advance(const Duration(seconds: 4)); + notifier.recordStopwatchLap(); + + expect(notifier.state.stopwatchLaps.length, equals(2)); + expect(notifier.state.stopwatchLaps.first.lapNumber, equals(2)); + expect(notifier.state.stopwatchLaps.first.splitTime, equals(const Duration(seconds: 4))); + }); + + test('Turn Timer: Advances players and increments round on loop', () { + notifier.setMode(TimerMode.turn); + notifier.configureTurnTimer( + turnDuration: const Duration(seconds: 30), + players: const [ + TurnPlayer(id: 'p1', name: 'Alice'), + TurnPlayer(id: 'p2', name: 'Bob'), + ], + ); + + expect(notifier.state.turnPlayers[notifier.state.activePlayerIndex].name, equals('Alice')); + expect(notifier.state.currentTurnRound, equals(1)); + + // Advance turn to Bob + notifier.advanceTurn(); + expect(notifier.state.turnPlayers[notifier.state.activePlayerIndex].name, equals('Bob')); + expect(notifier.state.currentTurnRound, equals(1)); + + // Advance turn back to Alice (New round) + notifier.advanceTurn(); + expect(notifier.state.turnPlayers[notifier.state.activePlayerIndex].name, equals('Alice')); + expect(notifier.state.currentTurnRound, equals(2)); + }); + + test('Chess Clock: Switches player and applies increment atomically', () { + notifier.setMode(TimerMode.chess); + notifier.configureChess( + baseTime: const Duration(minutes: 3), + increment: const Duration(seconds: 2), + ); + + notifier.start(); + expect(notifier.state.isPlayerATurn, isTrue); + + // Player A thinks for 10 seconds and moves + timeSource.advance(const Duration(seconds: 10)); + notifier.switchChessTurn(); + + expect(notifier.state.isPlayerATurn, isFalse); + expect(notifier.state.chessPlayerAMoves, equals(1)); + // Remaining = 3m - 10s + 2s inc = 2m 52s = 172s + expect(notifier.state.chessPlayerARemaining, equals(const Duration(seconds: 172))); + + // Player B thinks for 5 seconds and moves + timeSource.advance(const Duration(seconds: 5)); + notifier.switchChessTurn(); + + expect(notifier.state.isPlayerATurn, isTrue); + expect(notifier.state.chessPlayerBMoves, equals(1)); + // Remaining = 3m - 5s + 2s inc = 2m 57s = 177s + expect(notifier.state.chessPlayerBRemaining, equals(const Duration(seconds: 177))); + }); + + test('Round Timer: Work and rest transitions', () { + notifier.setMode(TimerMode.timer); + notifier.configureRoundTimer(const RoundTimerConfig( + workDuration: Duration(minutes: 1), + restDuration: Duration(seconds: 15), + totalRounds: 2, + )); + + expect(notifier.state.currentRound, equals(1)); + expect(notifier.state.intervalPhase, equals(IntervalPhase.work)); + expect(notifier.state.roundRemaining, equals(const Duration(minutes: 1))); + }); + }); + + group('UI Components & Presentation Tests', () { + testWidgets('TimerModeSelector renders all 5 modes', (tester) async { + TimerMode? selected; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TimerModeSelector( + selectedMode: TimerMode.countdown, + onModeChanged: (m) => selected = m, + ), + ), + ), + ); + + expect(find.text('Countdown'), findsOneWidget); + expect(find.text('Round Timer'), findsOneWidget); + expect(find.text('Stopwatch'), findsOneWidget); + expect(find.text('Turn Timer'), findsOneWidget); + expect(find.text('Chess Clock'), findsOneWidget); + + await tester.tap(find.text('Stopwatch')); + await tester.pump(); + expect(selected, equals(TimerMode.stopwatch)); + }); + + testWidgets('TimeDisplay renders tabular digits without error', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: TimeDisplay( + formattedTime: '05:00', + subtitle: 'Ready', + ), + ), + ), + ); + + expect(find.text('05:00'), findsOneWidget); + expect(find.text('Ready'), findsOneWidget); + }); + + testWidgets('LapList renders list of laps with split calculations', (tester) async { + const laps = [ + StopwatchLap(lapNumber: 2, splitTime: Duration(seconds: 4), totalTime: Duration(seconds: 14)), + StopwatchLap(lapNumber: 1, splitTime: Duration(seconds: 10), totalTime: Duration(seconds: 10)), + ]; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: LapList(laps: laps), + ), + ), + ); + + expect(find.text('Lap 02'), findsOneWidget); + expect(find.text('Lap 01'), findsOneWidget); + }); + + testWidgets('TimerControls renders Start, Pause, and Reset', (tester) async { + bool resetPressed = false; + bool startPressed = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TimerControls( + isRunning: false, + onStart: () => startPressed = true, + onPause: () {}, + onReset: () => resetPressed = true, + ), + ), + ), + ); + + expect(find.text('START'), findsOneWidget); + expect(find.text('RESET'), findsOneWidget); + + await tester.tap(find.text('START')); + expect(startPressed, isTrue); + + await tester.tap(find.text('RESET')); + expect(resetPressed, isTrue); + }); + }); + + group('Responsive Screen Testing Across Widths', () { + final testWidths = [320.0, 360.0, 390.0, 412.0, 768.0, 1024.0]; + + for (final width in testWidths) { + testWidgets('TimerScreen renders without overflow at ${width}px width', (tester) async { + tester.view.physicalSize = Size(width, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.lightTheme, + home: const TimerScreen(), + ), + ), + ); + + await tester.pump(); + expect(tester.takeException(), isNull, reason: 'No exception or overflow at width $width'); + expect(find.text('Timers'), findsOneWidget); + expect(find.byType(TimerModeSelector), findsOneWidget); + }); + } + + testWidgets('TimerScreen switches between all 5 modes without error', (tester) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.lightTheme, + home: const TimerScreen(), + ), + ), + ); + + await tester.pump(); + expect(find.text('Countdown'), findsOneWidget); + + // Switch to Round Timer + await tester.tap(find.text('Round Timer')); + await tester.pump(); + expect(tester.takeException(), isNull); + + // Switch to Stopwatch + await tester.tap(find.text('Stopwatch')); + await tester.pump(); + expect(tester.takeException(), isNull); + + // Switch to Turn Timer + await tester.tap(find.text('Turn Timer')); + await tester.pump(); + expect(tester.takeException(), isNull); + + // Switch to Chess Clock + await tester.tap(find.text('Chess Clock')); + await tester.pump(); + expect(tester.takeException(), isNull); + expect(find.text('PLAYER A'), findsOneWidget); + expect(find.text('PLAYER B'), findsOneWidget); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 02138a5..b9ba935 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -39,9 +39,9 @@ void main() { expect(find.text('PLAYMATE'), findsOneWidget); // Verify core feature cards are visible on home - expect(find.text('Dice Roller'), findsOneWidget); - expect(find.text('Coin Toss'), findsOneWidget); - expect(find.text('Score Tracker'), findsOneWidget); + expect(find.text('Dice Roller'), findsWidgets); + expect(find.text('Coin Toss'), findsWidgets); + expect(find.text('Score Tracker'), findsWidgets); }); testWidgets('Dice Roller screen navigates correctly', (WidgetTester tester) async { @@ -58,7 +58,7 @@ void main() { await tester.pumpAndSettle(); // Tap on Dice Roller card - await tester.tap(find.text('Dice Roller')); + await tester.tap(find.text('Dice Roller').first); await tester.pumpAndSettle(); // Verify navigation to Dice Roller