From b673921002ba7e5c623d6c34d59a328af60a39f4 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Wed, 8 Oct 2025 13:04:28 +0530 Subject: [PATCH] feat: add voice call functionality to chat page - Introduced a new button in the chat page's app bar to initiate voice calls. - Implemented the _handleVoiceCall method to navigate to the VoiceCallPage. - Enhanced user experience by providing a direct way to start voice calls from the chat interface. --- CLAUDE.md | 801 ++++++++++++++++++ .../chat/services/voice_call_service.dart | 390 +++++++++ lib/features/chat/views/chat_page.dart | 21 + lib/features/chat/views/voice_call_page.dart | 470 ++++++++++ openwebui-src | 2 +- 5 files changed, 1683 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md create mode 100644 lib/features/chat/services/voice_call_service.dart create mode 100644 lib/features/chat/views/voice_call_page.dart diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..247edda --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,801 @@ +# AI rules for Flutter + +You are an expert in Flutter and Dart development. Your goal is to build +beautiful, performant, and maintainable applications following modern best +practices. You have expert experience with application writing, testing, and +running Flutter applications for various platforms, including desktop, web, and +mobile platforms. + +## Interaction Guidelines +* **User Persona:** Assume the user is familiar with programming concepts but + may be new to Dart. +* **Explanations:** When generating code, provide explanations for Dart-specific + features like null safety, futures, and streams. +* **Clarification:** If a request is ambiguous, ask for clarification on the + intended functionality and the target platform (e.g., command-line, web, + server). +* **Dependencies:** When suggesting new dependencies from `pub.dev`, explain + their benefits. +* **Formatting:** Use the `dart_format` tool to ensure consistent code + formatting. +* **Fixes:** Use the `dart_fix` tool to automatically fix many common errors, + and to help code conform to configured analysis options. +* **Linting:** Use the Dart linter with a recommended set of rules to catch + common issues. Use the `analyze_files` tool to run the linter. + +## Project Structure +* **Standard Structure:** Assumes a standard Flutter project structure with + `lib/main.dart` as the primary application entry point. + +## Flutter style guide +* **SOLID Principles:** Apply SOLID principles throughout the codebase. +* **Concise and Declarative:** Write concise, modern, technical Dart code. + Prefer functional and declarative patterns. +* **Composition over Inheritance:** Favor composition for building complex + widgets and logic. +* **Immutability:** Prefer immutable data structures. Widgets (especially + `StatelessWidget`) should be immutable. +* **State Management:** Separate ephemeral state and app state. Use a state + management solution for app state to handle the separation of concerns. +* **Widgets are for UI:** Everything in Flutter's UI is a widget. Compose + complex UIs from smaller, reusable widgets. +* **Navigation:** Use a modern routing package like `auto_route` or `go_router`. + See the [navigation guide](./navigation.md) for a detailed example using + `go_router`. + +## Package Management +* **Pub Tool:** To manage packages, use the `pub` tool, if available. +* **External Packages:** If a new feature requires an external package, use the + `pub_dev_search` tool, if it is available. Otherwise, identify the most + suitable and stable package from pub.dev. +* **Adding Dependencies:** To add a regular dependency, use the `pub` tool, if + it is available. Otherwise, run `flutter pub add `. +* **Adding Dev Dependencies:** To add a development dependency, use the `pub` + tool, if it is available, with `dev:`. Otherwise, run `flutter + pub add dev:`. +* **Dependency Overrides:** To add a dependency override, use the `pub` tool, if + it is available, with `override::1.0.0`. Otherwise, run `flutter + pub add override::1.0.0`. +* **Removing Dependencies:** To remove a dependency, use the `pub` tool, if it + is available. Otherwise, run `dart pub remove `. + +## Code Quality +* **Code structure:** Adhere to maintainable code structure and separation of + concerns (e.g., UI logic separate from business logic). +* **Naming conventions:** Avoid abbreviations and use meaningful, consistent, + descriptive names for variables, functions, and classes. +* **Conciseness:** Write code that is as short as it can be while remaining + clear. +* **Simplicity:** Write straightforward code. Code that is clever or + obscure is difficult to maintain. +* **Error Handling:** Anticipate and handle potential errors. Don't let your + code fail silently. +* **Styling:** + * Line length: Lines should be 80 characters or fewer. + * Use `PascalCase` for classes, `camelCase` for + members/variables/functions/enums, and `snake_case` for files. +* **Functions:** + * Functions short and with a single purpose (strive for less than 20 lines). +* **Testing:** Write code with testing in mind. Use the `file`, `process`, and + `platform` packages, if appropriate, so you can inject in-memory and fake + versions of the objects. +* **Logging:** Use the `logging` package instead of `print`. + +## Dart Best Practices +* **Effective Dart:** Follow the official Effective Dart guidelines + (https://dart.dev/effective-dart) +* **Class Organization:** Define related classes within the same library file. + For large libraries, export smaller, private libraries from a single top-level + library. +* **Library Organization:** Group related libraries in the same folder. +* **API Documentation:** Add documentation comments to all public APIs, + including classes, constructors, methods, and top-level functions. +* **Comments:** Write clear comments for complex or non-obvious code. Avoid + over-commenting. +* **Trailing Comments:** Don't add trailing comments. +* **Async/Await:** Ensure proper use of `async`/`await` for asynchronous + operations with robust error handling. + * Use `Future`s, `async`, and `await` for asynchronous operations. + * Use `Stream`s for sequences of asynchronous events. +* **Null Safety:** Write code that is soundly null-safe. Leverage Dart's null + safety features. Avoid `!` unless the value is guaranteed to be non-null. +* **Pattern Matching:** Use pattern matching features where they simplify the + code. +* **Records:** Use records to return multiple types in situations where defining + an entire class is cumbersome. +* **Switch Statements:** Prefer using exhaustive `switch` statements or + expressions, which don't require `break` statements. +* **Exception Handling:** Use `try-catch` blocks for handling exceptions, and + use exceptions appropriate for the type of exception. Use custom exceptions + for situations specific to your code. +* **Arrow Functions:** Use arrow syntax for simple one-line functions. + +## Flutter Best Practices +* **Immutability:** Widgets (especially `StatelessWidget`) are immutable; when + the UI needs to change, Flutter rebuilds the widget tree. +* **Composition:** Prefer composing smaller widgets over extending existing + ones. Use this to avoid deep widget nesting. +* **Private Widgets:** Use small, private `Widget` classes instead of private + helper methods that return a `Widget`. +* **Build Methods:** Break down large `build()` methods into smaller, reusable + private Widget classes. +* **List Performance:** Use `ListView.builder` or `SliverList` for long lists to + create lazy-loaded lists for performance. +* **Isolates:** Use `compute()` to run expensive calculations in a separate + isolate to avoid blocking the UI thread, such as JSON parsing. +* **Const Constructors:** Use `const` constructors for widgets and in `build()` + methods whenever possible to reduce rebuilds. +* **Build Method Performance:** Avoid performing expensive operations, like + network calls or complex computations, directly within `build()` methods. + +## API Design Principles +When building reusable APIs, such as a library, follow these principles. + +* **Consider the User:** Design APIs from the perspective of the person who will + be using them. The API should be intuitive and easy to use correctly. +* **Documentation is Essential:** Good documentation is a part of good API + design. It should be clear, concise, and provide examples. + +## Application Architecture +* **Separation of Concerns:** Aim for separation of concerns similar to MVC/MVVM, with defined Model, + View, and ViewModel/Controller roles. +* **Logical Layers:** Organize the project into logical layers: + * Presentation (widgets, screens) + * Domain (business logic classes) + * Data (model classes, API clients) + * Core (shared classes, utilities, and extension types) +* **Feature-based Organization:** For larger projects, organize code by feature, + where each feature has its own presentation, domain, and data subfolders. This + improves navigability and scalability. + +## Lint Rules + +Include the package in the `analysis_options.yaml` file. Use the following +analysis_options.yaml file as a starting point: + +```yaml +include: package:flutter_lints/flutter.yaml + +linter: + rules: + # Add additional lint rules here: + # avoid_print: false + # prefer_single_quotes: true +``` + +### State Management +* **Riverpod 3.0:** This project uses Riverpod 3.0 for state management. +* **Code Generation:** Always use `@riverpod` annotation with code generation + for new providers. See existing examples in `lib/core/providers/`. +* **Notifier Classes:** Use `Notifier` and `AsyncNotifier` for mutable state: + ```dart + @riverpod + class Counter extends _$Counter { + @override + int build() => 0; + + void increment() => state++; + } + ``` +* **Provider Functions:** Use `@riverpod` functions for computed/derived state: + ```dart + @riverpod + int doubled(DoubledRef ref) { + final count = ref.watch(counterProvider); + return count * 2; + } + ``` +* **Keep Alive:** Use `@Riverpod(keepAlive: true)` for singletons: + ```dart + @Riverpod(keepAlive: true) + class AuthManager extends _$AuthManager { ... } + ``` +* **Async Safety:** Always check `ref.mounted` before state updates in async ops: + ```dart + Future loadData() async { + final data = await fetchData(); + if (!ref.mounted) return; // ✅ Prevent updates after disposal + state = data; + } + ``` +* **Automatic Retry:** Providers automatically retry on failure with exponential + backoff. Customize if needed: + ```dart + @riverpod + Future myData(MyDataRef ref) async { + ref.onDispose(() { + // Cleanup + }); + return await fetchData(); + } + ``` +* **Lint Rules:** Use `custom_lint` with `riverpod_lint` to catch common mistakes. + Run `dart run custom_lint` before committing. +* **MVVM Pattern:** Structure the app using Model-View-ViewModel with Riverpod + providers acting as ViewModels. + +### Data Flow +* **Data Structures:** Define data structures (classes) to represent the data + used in the application. +* **Data Abstraction:** Abstract data sources (e.g., API calls, database + operations) using Repositories/Services to promote testability. + +### Routing +* **GoRouter:** Use the `go_router` package for declarative navigation, deep + linking, and web support. +* **GoRouter Setup:** To use `go_router`, first add it to your `pubspec.yaml` + using the `pub` tool's `add` command. + + ```dart + // 1. Add the dependency + // flutter pub add go_router + + // 2. Configure the router + final GoRouter _router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const HomeScreen(), + routes: [ + GoRoute( + path: 'details/:id', // Route with a path parameter + builder: (context, state) { + final String id = state.pathParameters['id']!; + return DetailScreen(id: id); + }, + ), + ], + ), + ], + ); + + // 3. Use it in your MaterialApp + MaterialApp.router( + routerConfig: _router, + ); + ``` +* **Authentication Redirects:** Configure `go_router`'s `redirect` property to + handle authentication flows, ensuring users are redirected to the login screen + when unauthorized, and back to their intended destination after successful + login. + +* **Navigator:** Use the built-in `Navigator` for short-lived screens that do + not need to be deep-linkable, such as dialogs or temporary views. + + ```dart + // Push a new screen onto the stack + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const DetailsScreen()), + ); + + // Pop the current screen to go back + Navigator.pop(context); + ``` + +### Data Handling & Serialization +* **JSON Serialization:** Use `json_serializable` and `json_annotation` for + parsing and encoding JSON data. +* **Field Renaming:** When encoding data, use `fieldRename: FieldRename.snake` + to convert Dart's camelCase fields to snake_case JSON keys. + + ```dart + // In your model file + import 'package:json_annotation/json_annotation.dart'; + + part 'user.g.dart'; + + @JsonSerializable(fieldRename: FieldRename.snake) + class User { + final String firstName; + final String lastName; + + User({required this.firstName, required this.lastName}); + + factory User.fromJson(Map json) => _$UserFromJson(json); + Map toJson() => _$UserToJson(this); + } + ``` + + +### Logging +* **Structured Logging:** Use the `log` function from `dart:developer` for + structured logging that integrates with Dart DevTools. + + ```dart + import 'dart:developer' as developer; + + // For simple messages + developer.log('User logged in successfully.'); + + // For structured error logging + try { + // ... code that might fail + } catch (e, s) { + developer.log( + 'Failed to fetch data', + name: 'myapp.network', + level: 1000, // SEVERE + error: e, + stackTrace: s, + ); + } + ``` + +## Code Generation +* **Build Runner:** If the project uses code generation, ensure that + `build_runner` is listed as a dev dependency in `pubspec.yaml`. +* **Code Generation Tasks:** Use `build_runner` for all code generation tasks, + such as for `json_serializable`. +* **Running Build Runner:** After modifying files that require code generation, + run the build command: + + ```shell + dart run build_runner build --delete-conflicting-outputs + ``` + +## Testing +* **Running Tests:** To run tests, use the `run_tests` tool if it is available, + otherwise use `flutter test`. +* **Unit Tests:** Use `package:test` for unit tests. +* **Widget Tests:** Use `package:flutter_test` for widget tests. +* **Integration Tests:** Use `package:integration_test` for integration tests. +* **Assertions:** Prefer using `package:checks` for more expressive and readable + assertions over the default `matchers`. + +### Testing Best practices +* **Convention:** Follow the Arrange-Act-Assert (or Given-When-Then) pattern. +* **Unit Tests:** Write unit tests for domain logic, data layer, and state + management. +* **Widget Tests:** Write widget tests for UI components. +* **Integration Tests:** For broader application validation, use integration + tests to verify end-to-end user flows. +* **integration_test package:** Use the `integration_test` package from the + Flutter SDK for integration tests. Add it as a `dev_dependency` in + `pubspec.yaml` by specifying `sdk: flutter`. +* **Mocks:** Prefer fakes or stubs over mocks. If mocks are absolutely + necessary, use `mockito` or `mocktail` to create mocks for dependencies. While + code generation is common for state management (e.g., with `freezed`), try to + avoid it for mocks. +* **Coverage:** Aim for high test coverage. + +## Visual Design & Theming +* **UI Design:** Build beautiful and intuitive user interfaces that follow + modern design guidelines. +* **Responsiveness:** Ensure the app is mobile responsive and adapts to + different screen sizes, working perfectly on mobile and web. +* **Navigation:** If there are multiple pages for the user to interact with, + provide an intuitive and easy navigation bar or controls. +* **Typography:** Stress and emphasize font sizes to ease understanding, e.g., + hero text, section headlines, list headlines, keywords in paragraphs. +* **Background:** Apply subtle noise texture to the main background to add a + premium, tactile feel. +* **Shadows:** Multi-layered drop shadows create a strong sense of depth; cards + have a soft, deep shadow to look "lifted." +* **Icons:** Incorporate icons to enhance the user’s understanding and the + logical navigation of the app. +* **Interactive Elements:** Buttons, checkboxes, sliders, lists, charts, graphs, + and other interactive elements have a shadow with elegant use of color to + create a "glow" effect. + +### Theming +* **Centralized Theme:** Define a centralized `ThemeData` object to ensure a + consistent application-wide style. +* **Light and Dark Themes:** Implement support for both light and dark themes, + ideal for a user-facing theme toggle (`ThemeMode.light`, `ThemeMode.dark`, + `ThemeMode.system`). +* **Color Scheme Generation:** Generate harmonious color palettes from a single + color using `ColorScheme.fromSeed`. + + ```dart + final ThemeData lightTheme = ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.deepPurple, + brightness: Brightness.light, + ), + // ... other theme properties + ); + ``` +* **Color Palette:** Include a wide range of color concentrations and hues in + the palette to create a vibrant and energetic look and feel. +* **Component Themes:** Use specific theme properties (e.g., `appBarTheme`, + `elevatedButtonTheme`) to customize the appearance of individual Material + components. +* **Custom Fonts:** For custom fonts, use the `google_fonts` package. Define a + `TextTheme` to apply fonts consistently. + + ```dart + // 1. Add the dependency + // flutter pub add google_fonts + + // 2. Define a TextTheme with a custom font + final TextTheme appTextTheme = TextTheme( + displayLarge: GoogleFonts.oswald(fontSize: 57, fontWeight: FontWeight.bold), + titleLarge: GoogleFonts.roboto(fontSize: 22, fontWeight: FontWeight.w500), + bodyMedium: GoogleFonts.openSans(fontSize: 14), + ); + ``` + +### Assets and Images +* **Image Guidelines:** If images are needed, make them relevant and meaningful, + with appropriate size, layout, and licensing (e.g., freely available). Provide + placeholder images if real ones are not available. +* **Asset Declaration:** Declare all asset paths in your `pubspec.yaml` file. + + ```yaml + flutter: + uses-material-design: true + assets: + - assets/images/ + ``` + +* **Local Images:** Use `Image.asset` for local images from your asset + bundle. + + ```dart + Image.asset('assets/images/placeholder.png') + ``` +* **Network images:** Use NetworkImage for images loaded from the network. +* **Cached images:** For cached images, use NetworkImage a package like + `cached_network_image`. +* **Custom Icons:** Use `ImageIcon` to display an icon from an `ImageProvider`, + useful for custom icons not in the `Icons` class. +* **Network Images:** Use `Image.network` to display images from a URL, and + always include `loadingBuilder` and `errorBuilder` for a better user + experience. + + ```dart + Image.network( + 'https://picsum.photos/200/300', + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return const Center(child: CircularProgressIndicator()); + }, + errorBuilder: (context, error, stackTrace) { + return const Icon(Icons.error); + }, + ) + ``` +## UI Theming and Styling Code + +* **Responsiveness:** Use `LayoutBuilder` or `MediaQuery` to create responsive + UIs. +* **Text:** Use `Theme.of(context).textTheme` for text styles. +* **Text Fields:** Configure `textCapitalization`, `keyboardType`, and +* **Responsiveness:** Use `LayoutBuilder` or `MediaQuery` to create responsive + UIs. +* **Text:** Use `Theme.of(context).textTheme` for text styles. + remote images. + +```dart +// When using network images, always provide an errorBuilder. +Image.network( + 'https://example.com/image.png', + errorBuilder: (context, error, stackTrace) { + return const Icon(Icons.error); // Show an error icon + }, +); +``` + +## Material Theming Best Practices + +### Embrace `ThemeData` and Material 3 + +* **Use `ColorScheme.fromSeed()`:** Use this to generate a complete, harmonious + color palette for both light and dark modes from a single seed color. +* **Define Light and Dark Themes:** Provide both `theme` and `darkTheme` to your + `MaterialApp` to support system brightness settings seamlessly. +* **Centralize Component Styles:** Customize specific component themes (e.g., + `elevatedButtonTheme`, `cardTheme`, `appBarTheme`) within `ThemeData` to + ensure consistency. +* **Dark/Light Mode and Theme Toggle:** Implement support for both light and + dark themes using `theme` and `darkTheme` properties of `MaterialApp`. The + `themeMode` property can be dynamically controlled (e.g., via a + `ChangeNotifierProvider`) to allow for toggling between `ThemeMode.light`, + `ThemeMode.dark`, or `ThemeMode.system`. + +```dart +// main.dart +MaterialApp( + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.deepPurple, + brightness: Brightness.light, + ), + textTheme: const TextTheme( + displayLarge: TextStyle(fontSize: 57.0, fontWeight: FontWeight.bold), + bodyMedium: TextStyle(fontSize: 14.0, height: 1.4), + ), + ), + darkTheme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.deepPurple, + brightness: Brightness.dark, + ), + ), + home: const MyHomePage(), +); +``` + +### Implement Design Tokens with `ThemeExtension` + +For custom styles that aren't part of the standard `ThemeData`, use +`ThemeExtension` to define reusable design tokens. + +* **Create a Custom Theme Extension:** Define a class that extends + `ThemeExtension` and include your custom properties. +* **Implement `copyWith` and `lerp`:** These methods are required for the + extension to work correctly with theme transitions. +* **Register in `ThemeData`:** Add your custom extension to the `extensions` + list in your `ThemeData`. +* **Access Tokens in Widgets:** Use `Theme.of(context).extension()!` + to access your custom tokens. + +```dart +// 1. Define the extension +@immutable +class MyColors extends ThemeExtension { + const MyColors({required this.success, required this.danger}); + + final Color? success; + final Color? danger; + + @override + ThemeExtension copyWith({Color? success, Color? danger}) { + return MyColors(success: success ?? this.success, danger: danger ?? this.danger); + } + + @override + ThemeExtension lerp(ThemeExtension? other, double t) { + if (other is! MyColors) return this; + return MyColors( + success: Color.lerp(success, other.success, t), + danger: Color.lerp(danger, other.danger, t), + ); + } +} + +// 2. Register it in ThemeData +theme: ThemeData( + extensions: const >[ + MyColors(success: Colors.green, danger: Colors.red), + ], +), + +// 3. Use it in a widget +Container( + color: Theme.of(context).extension()!.success, +) +``` + +### Styling with `WidgetStateProperty` + +* **`WidgetStateProperty.resolveWith`:** Provide a function that receives a + `Set` and returns the appropriate value for the current state. +* **`WidgetStateProperty.all`:** A shorthand for when the value is the same for + all states. + +```dart +// Example: Creating a button style that changes color when pressed. +final ButtonStyle myButtonStyle = ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.pressed)) { + return Colors.green; // Color when pressed + } + return Colors.red; // Default color + }, + ), +); +``` + +## Layout Best Practices + +### Building Flexible and Overflow-Safe Layouts + +#### For Rows and Columns + +* **`Expanded`:** Use to make a child widget fill the remaining available space + along the main axis. +* **`Flexible`:** Use when you want a widget to shrink to fit, but not + necessarily grow. Don't combine `Flexible` and `Expanded` in the same `Row` or + `Column`. +* **`Wrap`:** Use when you have a series of widgets that would overflow a `Row` + or `Column`, and you want them to move to the next line. + +#### For General Content + +* **`SingleChildScrollView`:** Use when your content is intrinsically larger + than the viewport, but is a fixed size. +* **`ListView` / `GridView`:** For long lists or grids of content, always use a + builder constructor (`.builder`). +* **`FittedBox`:** Use to scale or fit a single child widget within its parent. +* **`LayoutBuilder`:** Use for complex, responsive layouts to make decisions + based on the available space. + +### Layering Widgets with Stack + +* **`Positioned`:** Use to precisely place a child within a `Stack` by anchoring it to the edges. +* **`Align`:** Use to position a child within a `Stack` using alignments like `Alignment.center`. + +### Advanced Layout with Overlays + +* **`OverlayPortal`:** Use this widget to show UI elements (like custom + dropdowns or tooltips) "on top" of everything else. It manages the + `OverlayEntry` for you. + + ```dart + class MyDropdown extends StatefulWidget { + const MyDropdown({super.key}); + + @override + State createState() => _MyDropdownState(); + } + + class _MyDropdownState extends State { + final _controller = OverlayPortalController(); + + @override + Widget build(BuildContext context) { + return OverlayPortal( + controller: _controller, + overlayChildBuilder: (BuildContext context) { + return const Positioned( + top: 50, + left: 10, + child: Card( + child: Padding( + padding: EdgeInsets.all(8.0), + child: Text('I am an overlay!'), + ), + ), + ); + }, + child: ElevatedButton( + onPressed: _controller.toggle, + child: const Text('Toggle Overlay'), + ), + ); + } + } + ``` + +## Color Scheme Best Practices + +### Contrast Ratios + +* **WCAG Guidelines:** Aim to meet the Web Content Accessibility Guidelines + (WCAG) 2.1 standards. +* **Minimum Contrast:** + * **Normal Text:** A contrast ratio of at least **4.5:1**. + * **Large Text:** (18pt or 14pt bold) A contrast ratio of at least **3:1**. + +### Palette Selection + +* **Primary, Secondary, and Accent:** Define a clear color hierarchy. +* **The 60-30-10 Rule:** A classic design rule for creating a balanced color scheme. + * **60%** Primary/Neutral Color (Dominant) + * **30%** Secondary Color + * **10%** Accent Color + +### Complementary Colors + +* **Use with Caution:** They can be visually jarring if overused. +* **Best Use Cases:** They are excellent for accent colors to make specific + elements pop, but generally poor for text and background pairings as they can + cause eye strain. + +### Example Palette + +* **Primary:** #0D47A1 (Dark Blue) +* **Secondary:** #1976D2 (Medium Blue) +* **Accent:** #FFC107 (Amber) +* **Neutral/Text:** #212121 (Almost Black) +* **Background:** #FEFEFE (Almost White) + +## Font Best Practices + +### Font Selection + +* **Limit Font Families:** Stick to one or two font families for the entire + application. +* **Prioritize Legibility:** Choose fonts that are easy to read on screens of + all sizes. Sans-serif fonts are generally preferred for UI body text. +* **System Fonts:** Consider using platform-native system fonts. +* **Google Fonts:** For a wide selection of open-source fonts, use the + `google_fonts` package. + +### Hierarchy and Scale + +* **Establish a Scale:** Define a set of font sizes for different text elements + (e.g., headlines, titles, body text, captions). +* **Use Font Weight:** Differentiate text effectively using font weights. +* **Color and Opacity:** Use color and opacity to de-emphasize less important + text. + +### Readability + +* **Line Height (Leading):** Set an appropriate line height, typically **1.4x to + 1.6x** the font size. +* **Line Length:** For body text, aim for a line length of **45-75 characters**. +* **Avoid All Caps:** Do not use all caps for long-form text. + +### Example Typographic Scale + +```dart +// In your ThemeData +textTheme: const TextTheme( + displayLarge: TextStyle(fontSize: 57.0, fontWeight: FontWeight.bold), + titleLarge: TextStyle(fontSize: 22.0, fontWeight: FontWeight.bold), + bodyLarge: TextStyle(fontSize: 16.0, height: 1.5), + bodyMedium: TextStyle(fontSize: 14.0, height: 1.4), + labelSmall: TextStyle(fontSize: 11.0, color: Colors.grey), +), +``` + +## Documentation + +* **`dartdoc`:** Write `dartdoc`-style comments for all public APIs. + + +### Documentation Philosophy + +* **Comment wisely:** Use comments to explain why the code is written a certain + way, not what the code does. The code itself should be self-explanatory. +* **Document for the user:** Write documentation with the reader in mind. If you + had a question and found the answer, add it to the documentation where you + first looked. This ensures the documentation answers real-world questions. +* **No useless documentation:** If the documentation only restates the obvious + from the code's name, it's not helpful. Good documentation provides context + and explains what isn't immediately apparent. +* **Consistency is key:** Use consistent terminology throughout your + documentation. + +### Commenting Style + +* **Use `///` for doc comments:** This allows documentation generation tools to + pick them up. +* **Start with a single-sentence summary:** The first sentence should be a + concise, user-centric summary ending with a period. +* **Separate the summary:** Add a blank line after the first sentence to create + a separate paragraph. This helps tools create better summaries. +* **Avoid redundancy:** Don't repeat information that's obvious from the code's + context, like the class name or signature. +* **Don't document both getter and setter:** For properties with both, only + document one. The documentation tool will treat them as a single field. + +### Writing Style + +* **Be brief:** Write concisely. +* **Avoid jargon and acronyms:** Don't use abbreviations unless they are widely + understood. +* **Use Markdown sparingly:** Avoid excessive markdown and never use HTML for + formatting. +* **Use backticks for code:** Enclose code blocks in backtick fences, and + specify the language. + +### What to Document + +* **Public APIs are a priority:** Always document public APIs. +* **Consider private APIs:** It's a good idea to document private APIs as well. +* **Library-level comments are helpful:** Consider adding a doc comment at the + library level to provide a general overview. +* **Include code samples:** Where appropriate, add code samples to illustrate usage. +* **Explain parameters, return values, and exceptions:** Use prose to describe + what a function expects, what it returns, and what errors it might throw. +* **Place doc comments before annotations:** Documentation should come before + any metadata annotations. + +## Accessibility (A11Y) +Implement accessibility features to empower all users, assuming a wide variety +of users with different physical abilities, mental abilities, age groups, +education levels, and learning styles. + +* **Color Contrast:** Ensure text has a contrast ratio of at least **4.5:1** + against its background. +* **Dynamic Text Scaling:** Test your UI to ensure it remains usable when users + increase the system font size. +* **Semantic Labels:** Use the `Semantics` widget to provide clear, descriptive + labels for UI elements. +* **Screen Reader Testing:** Regularly test your app with TalkBack (Android) and + VoiceOver (iOS). \ No newline at end of file diff --git a/lib/features/chat/services/voice_call_service.dart b/lib/features/chat/services/voice_call_service.dart new file mode 100644 index 0000000..f1a3b8d --- /dev/null +++ b/lib/features/chat/services/voice_call_service.dart @@ -0,0 +1,390 @@ +import 'dart:async'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../core/providers/app_providers.dart'; +import '../../../core/services/socket_service.dart'; +import '../providers/chat_providers.dart'; +import 'text_to_speech_service.dart'; +import 'voice_input_service.dart'; + +part 'voice_call_service.g.dart'; + +enum VoiceCallState { + idle, + connecting, + listening, + processing, + speaking, + error, + disconnected, +} + +class VoiceCallService { + final VoiceInputService _voiceInput; + final TextToSpeechService _tts; + final SocketService _socketService; + final Ref _ref; + + VoiceCallState _state = VoiceCallState.idle; + String? _sessionId; + StreamSubscription? _transcriptSubscription; + StreamSubscription? _intensitySubscription; + String _accumulatedTranscript = ''; + bool _isDisposed = false; + SocketEventSubscription? _socketSubscription; + + final StreamController _stateController = + StreamController.broadcast(); + final StreamController _transcriptController = + StreamController.broadcast(); + final StreamController _responseController = + StreamController.broadcast(); + final StreamController _intensityController = + StreamController.broadcast(); + + VoiceCallService({ + required VoiceInputService voiceInput, + required TextToSpeechService tts, + required SocketService socketService, + required Ref ref, + }) : _voiceInput = voiceInput, + _tts = tts, + _socketService = socketService, + _ref = ref { + _tts.bindHandlers( + onStart: _handleTtsStart, + onComplete: _handleTtsComplete, + onError: _handleTtsError, + ); + } + + VoiceCallState get state => _state; + Stream get stateStream => _stateController.stream; + Stream get transcriptStream => _transcriptController.stream; + Stream get responseStream => _responseController.stream; + Stream get intensityStream => _intensityController.stream; + + Future initialize() async { + if (_isDisposed) return; + + // ignore: avoid_print + print('[VoiceCall] Starting initialization...'); + + // Initialize voice input + final voiceInitialized = await _voiceInput.initialize(); + // ignore: avoid_print + print('[VoiceCall] Voice initialized: $voiceInitialized'); + if (!voiceInitialized) { + _updateState(VoiceCallState.error); + throw Exception('Voice input initialization failed'); + } + + // Check if local STT is available + final hasLocalStt = _voiceInput.hasLocalStt; + // ignore: avoid_print + print('[VoiceCall] Has local STT: $hasLocalStt'); + if (!hasLocalStt) { + _updateState(VoiceCallState.error); + throw Exception('Speech recognition not available on this device'); + } + + // Check microphone permissions + final hasMicPermission = await _voiceInput.checkPermissions(); + // ignore: avoid_print + print('[VoiceCall] Has mic permission: $hasMicPermission'); + if (!hasMicPermission) { + _updateState(VoiceCallState.error); + throw Exception('Microphone permission not granted'); + } + + // Initialize TTS + await _tts.initialize(); + // ignore: avoid_print + print('[VoiceCall] TTS initialized'); + } + + Future startCall(String? conversationId) async { + // ignore: avoid_print + print('[VoiceCall] startCall() entered. _isDisposed=$_isDisposed'); + + if (_isDisposed) { + // ignore: avoid_print + print('[VoiceCall] EARLY RETURN: Service is disposed'); + return; + } + + try { + // ignore: avoid_print + print('[VoiceCall] Starting call for conversation: $conversationId'); + _updateState(VoiceCallState.connecting); + + // Ensure socket connection + // ignore: avoid_print + print('[VoiceCall] Ensuring socket connection...'); + await _socketService.ensureConnected(); + _sessionId = _socketService.sessionId; + // ignore: avoid_print + print('[VoiceCall] Session ID: $_sessionId'); + + if (_sessionId == null) { + throw Exception('Failed to establish socket connection'); + } + + // Set up socket event listener for assistant responses + // ignore: avoid_print + print('[VoiceCall] Setting up socket event handler...'); + _socketSubscription = _socketService.addChatEventHandler( + conversationId: conversationId, + sessionId: _sessionId, + requireFocus: false, + handler: _handleSocketEvent, + ); + + // Start listening for user voice input + // ignore: avoid_print + print('[VoiceCall] Starting to listen...'); + await _startListening(); + // ignore: avoid_print + print('[VoiceCall] Listen started successfully'); + } catch (e) { + // ignore: avoid_print + print('[VoiceCall] Error in startCall: $e'); + _updateState(VoiceCallState.error); + rethrow; + } + } + + Future _startListening() async { + if (_isDisposed) return; + + try { + _accumulatedTranscript = ''; + + // ignore: avoid_print + print('[VoiceCall] _startListening called'); + + // Check if voice input is available + if (!_voiceInput.hasLocalStt) { + // ignore: avoid_print + print('[VoiceCall] ERROR: No local STT available'); + _updateState(VoiceCallState.error); + throw Exception('Voice input not available on this device'); + } + + // ignore: avoid_print + print('[VoiceCall] Setting state to listening...'); + _updateState(VoiceCallState.listening); + + // ignore: avoid_print + print('[VoiceCall] Calling beginListening...'); + final stream = await _voiceInput.beginListening(); + // ignore: avoid_print + print('[VoiceCall] Got stream from beginListening'); + + _transcriptSubscription = stream.listen( + (text) { + // ignore: avoid_print + print('[VoiceCall] Transcript received: $text'); + if (_isDisposed) return; + _accumulatedTranscript = text; + _transcriptController.add(text); + }, + onError: (error) { + // ignore: avoid_print + print('[VoiceCall] Stream error: $error'); + if (_isDisposed) return; + _updateState(VoiceCallState.error); + }, + onDone: () async { + // ignore: avoid_print + print('[VoiceCall] Stream done. Transcript: $_accumulatedTranscript'); + if (_isDisposed) return; + // User stopped speaking, send message to assistant + if (_accumulatedTranscript.trim().isNotEmpty) { + await _sendMessageToAssistant(_accumulatedTranscript); + } else { + // No input, restart listening + await _startListening(); + } + }, + ); + + // ignore: avoid_print + print('[VoiceCall] Setting up intensity stream...'); + // Forward intensity stream for waveform visualization + _intensitySubscription = _voiceInput.intensityStream.listen( + (intensity) { + if (_isDisposed) return; + _intensityController.add(intensity); + }, + ); + // ignore: avoid_print + print('[VoiceCall] _startListening completed successfully'); + } catch (e) { + // ignore: avoid_print + print('[VoiceCall] ERROR in _startListening: $e'); + _updateState(VoiceCallState.error); + rethrow; + } + } + + Future _sendMessageToAssistant(String text) async { + if (_isDisposed) return; + + try { + _updateState(VoiceCallState.processing); + + // Send message using the existing chat infrastructure + sendMessageFromService(_ref, text, null); + } catch (e) { + _updateState(VoiceCallState.error); + rethrow; + } + } + + void _handleSocketEvent( + Map event, + void Function(dynamic response)? ack, + ) { + if (_isDisposed) return; + + final type = event['type']?.toString(); + final data = event['data']; + + if (data is Map) { + // Handle streaming response chunks + if (type == 'message' || type == 'delta') { + final content = data['content']?.toString() ?? ''; + if (content.isNotEmpty) { + _responseController.add(content); + } + } + + // Handle completion + if (data['done'] == true || type == 'completion') { + final fullResponse = data['content']?.toString() ?? + data['message']?.toString() ?? + ''; + if (fullResponse.isNotEmpty) { + _speakResponse(fullResponse); + } else { + // No response, restart listening + _startListening(); + } + } + } + } + + Future _speakResponse(String response) async { + if (_isDisposed) return; + + try { + _updateState(VoiceCallState.speaking); + await _tts.speak(response); + // After speaking completes, _handleTtsComplete will restart listening + } catch (e) { + _updateState(VoiceCallState.error); + // Restart listening even if TTS fails + await _startListening(); + } + } + + void _handleTtsStart() { + if (_isDisposed) return; + _updateState(VoiceCallState.speaking); + } + + void _handleTtsComplete() { + if (_isDisposed) return; + // After assistant finishes speaking, start listening for user again + _startListening(); + } + + void _handleTtsError(String error) { + if (_isDisposed) return; + _updateState(VoiceCallState.error); + // Try to recover by restarting listening + _startListening(); + } + + Future stopCall() async { + if (_isDisposed) return; + + await _transcriptSubscription?.cancel(); + await _intensitySubscription?.cancel(); + _socketSubscription?.dispose(); + + await _voiceInput.stopListening(); + await _tts.stop(); + + _sessionId = null; + _accumulatedTranscript = ''; + _updateState(VoiceCallState.disconnected); + } + + Future pauseListening() async { + if (_isDisposed) return; + await _voiceInput.stopListening(); + await _transcriptSubscription?.cancel(); + await _intensitySubscription?.cancel(); + } + + Future resumeListening() async { + if (_isDisposed) return; + await _startListening(); + } + + Future cancelSpeaking() async { + if (_isDisposed) return; + await _tts.stop(); + // Immediately restart listening + await _startListening(); + } + + void _updateState(VoiceCallState newState) { + if (_isDisposed) return; + _state = newState; + _stateController.add(newState); + } + + Future dispose() async { + _isDisposed = true; + + await _transcriptSubscription?.cancel(); + await _intensitySubscription?.cancel(); + _socketSubscription?.dispose(); + + _voiceInput.dispose(); + await _tts.dispose(); + + await _stateController.close(); + await _transcriptController.close(); + await _responseController.close(); + await _intensityController.close(); + } +} + +@Riverpod(keepAlive: true) +VoiceCallService voiceCallService(Ref ref) { + final voiceInput = ref.watch(voiceInputServiceProvider); + final tts = TextToSpeechService(); + final socketService = ref.watch(socketServiceProvider); + + if (socketService == null) { + throw Exception('Socket service not available'); + } + + final service = VoiceCallService( + voiceInput: voiceInput, + tts: tts, + socketService: socketService, + ref: ref, + ); + + ref.onDispose(() { + service.dispose(); + }); + + return service; +} diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index c33851a..b83321f 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -24,6 +24,7 @@ import '../widgets/file_attachment_widget.dart'; // import '../widgets/voice_input_sheet.dart'; // deprecated: replaced by inline voice input import '../services/voice_input_service.dart'; import '../services/file_attachment_service.dart'; +import 'voice_call_page.dart'; import 'package:path/path.dart' as path; import '../../../shared/services/tasks/task_queue.dart'; import '../../tools/providers/tools_providers.dart'; @@ -529,6 +530,16 @@ class _ChatPageState extends ConsumerState { } } + void _handleVoiceCall() { + // Navigate to voice call page + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const VoiceCallPage(), + fullscreenDialog: true, + ), + ); + } + // Replaced bottom-sheet chat list with left drawer (see ChatsDrawer) void _onScroll() { @@ -1434,6 +1445,16 @@ class _ChatPageState extends ConsumerState { ), actions: [ if (!_isSelectionMode) ...[ + // Voice call button + IconButton( + icon: Icon( + CupertinoIcons.phone, + color: context.conduitTheme.textPrimary, + size: IconSize.appBar, + ), + onPressed: _handleVoiceCall, + tooltip: 'Voice Call', + ), Padding( padding: const EdgeInsets.only(right: Spacing.inputPadding), child: IconButton( diff --git a/lib/features/chat/views/voice_call_page.dart b/lib/features/chat/views/voice_call_page.dart new file mode 100644 index 0000000..9af2d3f --- /dev/null +++ b/lib/features/chat/views/voice_call_page.dart @@ -0,0 +1,470 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/providers/app_providers.dart'; +import '../services/voice_call_service.dart'; + +class VoiceCallPage extends ConsumerStatefulWidget { + const VoiceCallPage({super.key}); + + @override + ConsumerState createState() => _VoiceCallPageState(); +} + +class _VoiceCallPageState extends ConsumerState + with TickerProviderStateMixin { + VoiceCallService? _service; + StreamSubscription? _stateSubscription; + StreamSubscription? _transcriptSubscription; + StreamSubscription? _responseSubscription; + StreamSubscription? _intensitySubscription; + + VoiceCallState _currentState = VoiceCallState.idle; + String _currentTranscript = ''; + String _currentResponse = ''; + int _currentIntensity = 0; + + late AnimationController _pulseController; + late AnimationController _waveController; + + @override + void initState() { + super.initState(); + + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(reverse: true); + + _waveController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2000), + )..repeat(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _initializeCall(); + }); + } + + Future _initializeCall() async { + try { + // ignore: avoid_print + print('[VoiceCallPage] _initializeCall started'); + + _service = ref.read(voiceCallServiceProvider); + // ignore: avoid_print + print('[VoiceCallPage] Service instance: ${_service.hashCode}'); + + // Subscribe to service streams + _stateSubscription = _service!.stateStream.listen((state) { + if (mounted) { + setState(() { + _currentState = state; + }); + } + }); + + _transcriptSubscription = _service!.transcriptStream.listen((text) { + if (mounted) { + setState(() { + _currentTranscript = text; + }); + } + }); + + _responseSubscription = _service!.responseStream.listen((text) { + if (mounted) { + setState(() { + _currentResponse = text; + }); + } + }); + + _intensitySubscription = _service!.intensityStream.listen((intensity) { + if (mounted) { + setState(() { + _currentIntensity = intensity; + }); + } + }); + + // Initialize and start the call + // ignore: avoid_print + print('[VoiceCallPage] About to initialize service'); + await _service!.initialize(); + // ignore: avoid_print + print('[VoiceCallPage] Service initialized, reading activeConversation'); + final activeConversation = ref.read(activeConversationProvider); + // ignore: avoid_print + print('[VoiceCallPage] Active conversation: ${activeConversation?.id}'); + // ignore: avoid_print + print('[VoiceCallPage] About to call startCall'); + await _service!.startCall(activeConversation?.id); + // ignore: avoid_print + print('[VoiceCallPage] startCall completed'); + } catch (e) { + if (mounted) { + // Show error details in a debug-friendly way + final errorMessage = e.toString(); + _showErrorDialog(errorMessage); + // Also print to console for debugging + // ignore: avoid_print + print('[VoiceCallPage] ERROR during initialization: $errorMessage'); + // ignore: avoid_print + print('[VoiceCallPage] Stack trace: ${StackTrace.current}'); + } + } + } + + void _showErrorDialog(String message) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Error'), + content: Text(message), + actions: [ + TextButton( + onPressed: () { + Navigator.of(ctx).pop(); + if (mounted) { + Navigator.of(context).pop(); + } + }, + child: const Text('OK'), + ), + ], + ), + ); + } + + @override + void dispose() { + _stateSubscription?.cancel(); + _transcriptSubscription?.cancel(); + _responseSubscription?.cancel(); + _intensitySubscription?.cancel(); + _service?.stopCall(); + _pulseController.dispose(); + _waveController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final selectedModel = ref.watch(selectedModelProvider); + final primaryColor = Theme.of(context).colorScheme.primary; + final backgroundColor = Theme.of(context).scaffoldBackgroundColor; + final textColor = Theme.of(context).colorScheme.onSurface; + + return Scaffold( + backgroundColor: backgroundColor, + appBar: AppBar( + title: const Text('Voice Call'), + leading: IconButton( + icon: const Icon(CupertinoIcons.xmark), + onPressed: () async { + await _service?.stopCall(); + if (mounted) { + Navigator.of(context).pop(); + } + }, + ), + ), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Model name + Text( + selectedModel?.name ?? '', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: textColor.withOpacity(0.7), + ), + ), + const SizedBox(height: 48), + + // Animated waveform/status indicator + _buildStatusIndicator(primaryColor, textColor), + + const SizedBox(height: 48), + + // State label + Text( + _getStateLabel(), + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + + const SizedBox(height: 32), + + // Transcript or response text + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: _buildTextDisplay(textColor), + ), + + // Error state help text + if (_currentState == VoiceCallState.error) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + child: Text( + 'Please check:\n' + '• Microphone permissions are granted\n' + '• Speech recognition is available on your device\n' + '• You are connected to the server', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.error, + height: 1.5, + ), + ), + ), + ], + ), + ), + + // Control buttons + Padding( + padding: const EdgeInsets.all(32), + child: _buildControlButtons(primaryColor), + ), + ], + ), + ), + ); + } + + Widget _buildStatusIndicator(Color primaryColor, Color textColor) { + if (_currentState == VoiceCallState.listening) { + // Animated waveform bars + return SizedBox( + height: 120, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: List.generate(5, (index) { + return AnimatedBuilder( + animation: _waveController, + builder: (context, child) { + final offset = (index * 0.2) % 1.0; + final animation = (_waveController.value + offset) % 1.0; + final height = 20.0 + + (math.sin(animation * math.pi * 2) * 30.0).abs() + + (_currentIntensity * 4.0); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + width: 8, + height: height, + decoration: BoxDecoration( + color: primaryColor, + borderRadius: BorderRadius.circular(4), + ), + ); + }, + ); + }), + ), + ); + } else if (_currentState == VoiceCallState.speaking) { + // Pulsing circle for speaking + return AnimatedBuilder( + animation: _pulseController, + builder: (context, child) { + final scale = 1.0 + (_pulseController.value * 0.2); + return Transform.scale( + scale: scale, + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: primaryColor.withOpacity(0.2), + border: Border.all( + color: primaryColor, + width: 3, + ), + ), + child: Center( + child: Icon( + CupertinoIcons.speaker_2_fill, + size: 48, + color: primaryColor, + ), + ), + ), + ); + }, + ); + } else if (_currentState == VoiceCallState.processing) { + // Spinning loader for processing + return SizedBox( + width: 120, + height: 120, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(primaryColor), + ), + ); + } else { + // Default microphone icon + return Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: textColor.withOpacity(0.1), + ), + child: Icon( + CupertinoIcons.mic_fill, + size: 48, + color: textColor.withOpacity(0.5), + ), + ); + } + } + + Widget _buildTextDisplay(Color textColor) { + String displayText = ''; + + if (_currentState == VoiceCallState.listening && + _currentTranscript.isNotEmpty) { + displayText = _currentTranscript; + } else if (_currentState == VoiceCallState.speaking && + _currentResponse.isNotEmpty) { + displayText = _currentResponse; + } + + if (displayText.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + constraints: const BoxConstraints(maxHeight: 200), + child: SingleChildScrollView( + child: Text( + displayText, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: textColor.withOpacity(0.8), + height: 1.5, + ), + ), + ), + ); + } + + Widget _buildControlButtons(Color primaryColor) { + final errorColor = Theme.of(context).colorScheme.error; + final warningColor = Colors.orange; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Retry button (only show in error state) + if (_currentState == VoiceCallState.error) + _buildActionButton( + icon: CupertinoIcons.arrow_clockwise, + label: 'Retry', + color: primaryColor, + onPressed: () async { + await _initializeCall(); + }, + ), + + // Cancel speaking button (only show when speaking) + if (_currentState == VoiceCallState.speaking) + _buildActionButton( + icon: CupertinoIcons.stop_fill, + label: 'Stop', + color: warningColor, + onPressed: () async { + await _service?.cancelSpeaking(); + }, + ), + + // End call button + _buildActionButton( + icon: CupertinoIcons.phone_down_fill, + label: 'End Call', + color: errorColor, + onPressed: () async { + await _service?.stopCall(); + if (mounted) { + Navigator.of(context).pop(); + } + }, + ), + ], + ); + } + + Widget _buildActionButton({ + required IconData icon, + required String label, + required Color color, + required VoidCallback onPressed, + }) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: onPressed, + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + ), + child: Icon( + icon, + color: Colors.white, + size: 32, + ), + ), + ), + const SizedBox(height: 8), + Text( + label, + style: TextStyle( + fontSize: 12, + color: color, + ), + ), + ], + ); + } + + String _getStateLabel() { + switch (_currentState) { + case VoiceCallState.idle: + return 'Ready'; + case VoiceCallState.connecting: + return 'Connecting...'; + case VoiceCallState.listening: + return 'Listening'; + case VoiceCallState.processing: + return 'Thinking...'; + case VoiceCallState.speaking: + return 'Speaking'; + case VoiceCallState.error: + return 'Error'; + case VoiceCallState.disconnected: + return 'Disconnected'; + } + } +} diff --git a/openwebui-src b/openwebui-src index 598282c..46ae3f4 160000 --- a/openwebui-src +++ b/openwebui-src @@ -1 +1 @@ -Subproject commit 598282cf75de358215d045c617e70d28bc48929e +Subproject commit 46ae3f4f5d7d4d706041bdae4ad2d802e568712b